@mehshekari/gsap-motion 4.3.0 → 4.5.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.
@@ -1,126 +1,126 @@
1
- "use client";
2
-
3
- import { useGSAP } from "@gsap/react";
4
- import gsap from "gsap";
5
- import { ScrollTrigger } from "gsap/ScrollTrigger";
6
- import { useRef } from "react";
7
-
8
- /**
9
- * Starting point for a scroll-driven scene.
10
- *
11
- * The shape here is the lesson: one trigger owning one timeline, a genuinely
12
- * different strategy on mobile rather than a smaller one, and `ease: "none"`
13
- * throughout because the scrollbar — not an ease curve — is the playhead.
14
- *
15
- * Copy it, rename it, replace the beats. Typechecked for the same reason as
16
- * the sibling template.
17
- */
18
- gsap.registerPlugin(useGSAP, ScrollTrigger);
19
-
20
- /** Scroll distance the pinned sequence is spread across, in pixels. */
21
- const SCROLL_LENGTH = 2400;
22
-
23
- export function useScrollScene() {
24
- const rootRef = useRef<HTMLDivElement>(null);
25
-
26
- useGSAP(
27
- () => {
28
- const root = rootRef.current;
29
- if (!root) return;
30
-
31
- const mm = gsap.matchMedia();
32
-
33
- mm.add(
34
- {
35
- desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
36
- mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
37
- reduced: "(prefers-reduced-motion: reduce)",
38
- },
39
- (context) => {
40
- const { desktop, reduced } = context.conditions as Record<
41
- string,
42
- boolean
43
- >;
44
-
45
- /**
46
- * Scroll-scrubbed pinning is a textbook vestibular trigger: the
47
- * content moves while the page does not. Under `reduce` the beats
48
- * simply exist, composed, with no pin and no scrub.
49
- */
50
- if (reduced) {
51
- gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
52
- return;
53
- }
54
-
55
- /**
56
- * Not the desktop sequence scaled down. A 2400px pin on a phone is a
57
- * long time to be stuck, and pinning fights the address bar
58
- * collapsing — so each beat becomes an ordinary in-view reveal
59
- * instead. Same content, different strategy.
60
- */
61
- if (!desktop) {
62
- gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
63
- gsap.from(beat, {
64
- autoAlpha: 0,
65
- y: 24,
66
- duration: 0.6,
67
- ease: "power3.out",
68
- scrollTrigger: { trigger: beat, start: "top 78%" },
69
- });
70
- });
71
- return;
72
- }
73
-
74
- gsap
75
- .timeline({
76
- /**
77
- * `ease: "none"` on every child. Easing inside a scrubbed
78
- * timeline fights the visitor's own scrolling and reads as lag —
79
- * `scrub` is where the smoothing belongs.
80
- */
81
- defaults: { ease: "none" },
82
- scrollTrigger: {
83
- trigger: root,
84
- start: "top top",
85
- end: `+=${SCROLL_LENGTH}`,
86
- pin: true,
87
- scrub: 1,
88
- /** Reduces the flicker when the pin engages at speed. */
89
- anticipatePin: 1,
90
- /** Recomputes measured start values after a resize. */
91
- invalidateOnRefresh: true,
92
- markers: process.env.NODE_ENV === "development",
93
- },
94
- })
95
- /**
96
- * Labels rather than offsets. A scrubbed sequence gets retimed
97
- * many times, and two children sharing a label stay together when
98
- * the beat before them changes length.
99
- */
100
- .addLabel("open")
101
- .from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
102
-
103
- .addLabel("detail")
104
- .to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
105
- .from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
106
-
107
- /**
108
- * Placed at position 0 with the timeline's full span, so it tracks
109
- * overall progress regardless of what else is happening.
110
- */
111
- .to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
112
- },
113
- );
114
-
115
- /**
116
- * ScrollTriggers and the `matchMedia` above are both reverted with the
117
- * context — each registers itself when constructed inside one. Only
118
- * things GSAP never sees (timers, raw listeners, observers) need a
119
- * cleanup returned from here.
120
- */
121
- },
122
- { scope: rootRef },
123
- );
124
-
125
- return rootRef;
126
- }
1
+ "use client";
2
+
3
+ import { useGSAP } from "@gsap/react";
4
+ import gsap from "gsap";
5
+ import { ScrollTrigger } from "gsap/ScrollTrigger";
6
+ import { useRef } from "react";
7
+
8
+ /**
9
+ * Starting point for a scroll-driven scene.
10
+ *
11
+ * The shape here is the lesson: one trigger owning one timeline, a genuinely
12
+ * different strategy on mobile rather than a smaller one, and `ease: "none"`
13
+ * throughout because the scrollbar — not an ease curve — is the playhead.
14
+ *
15
+ * Copy it, rename it, replace the beats. Typechecked for the same reason as
16
+ * the sibling template.
17
+ */
18
+ gsap.registerPlugin(useGSAP, ScrollTrigger);
19
+
20
+ /** Scroll distance the pinned sequence is spread across, in pixels. */
21
+ const SCROLL_LENGTH = 2400;
22
+
23
+ export function useScrollScene() {
24
+ const rootRef = useRef<HTMLDivElement>(null);
25
+
26
+ useGSAP(
27
+ () => {
28
+ const root = rootRef.current;
29
+ if (!root) return;
30
+
31
+ const mm = gsap.matchMedia();
32
+
33
+ mm.add(
34
+ {
35
+ desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
36
+ mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
37
+ reduced: "(prefers-reduced-motion: reduce)",
38
+ },
39
+ (context) => {
40
+ const { desktop, reduced } = context.conditions as Record<
41
+ string,
42
+ boolean
43
+ >;
44
+
45
+ /**
46
+ * Scroll-scrubbed pinning is a textbook vestibular trigger: the
47
+ * content moves while the page does not. Under `reduce` the beats
48
+ * simply exist, composed, with no pin and no scrub.
49
+ */
50
+ if (reduced) {
51
+ gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
52
+ return;
53
+ }
54
+
55
+ /**
56
+ * Not the desktop sequence scaled down. A 2400px pin on a phone is a
57
+ * long time to be stuck, and pinning fights the address bar
58
+ * collapsing — so each beat becomes an ordinary in-view reveal
59
+ * instead. Same content, different strategy.
60
+ */
61
+ if (!desktop) {
62
+ gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
63
+ gsap.from(beat, {
64
+ autoAlpha: 0,
65
+ y: 24,
66
+ duration: 0.6,
67
+ ease: "power3.out",
68
+ scrollTrigger: { trigger: beat, start: "top 78%" },
69
+ });
70
+ });
71
+ return;
72
+ }
73
+
74
+ gsap
75
+ .timeline({
76
+ /**
77
+ * `ease: "none"` on every child. Easing inside a scrubbed
78
+ * timeline fights the visitor's own scrolling and reads as lag —
79
+ * `scrub` is where the smoothing belongs.
80
+ */
81
+ defaults: { ease: "none" },
82
+ scrollTrigger: {
83
+ trigger: root,
84
+ start: "top top",
85
+ end: `+=${SCROLL_LENGTH}`,
86
+ pin: true,
87
+ scrub: 1,
88
+ /** Reduces the flicker when the pin engages at speed. */
89
+ anticipatePin: 1,
90
+ /** Recomputes measured start values after a resize. */
91
+ invalidateOnRefresh: true,
92
+ markers: process.env.NODE_ENV === "development",
93
+ },
94
+ })
95
+ /**
96
+ * Labels rather than offsets. A scrubbed sequence gets retimed
97
+ * many times, and two children sharing a label stay together when
98
+ * the beat before them changes length.
99
+ */
100
+ .addLabel("open")
101
+ .from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
102
+
103
+ .addLabel("detail")
104
+ .to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
105
+ .from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
106
+
107
+ /**
108
+ * Placed at position 0 with the timeline's full span, so it tracks
109
+ * overall progress regardless of what else is happening.
110
+ */
111
+ .to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
112
+ },
113
+ );
114
+
115
+ /**
116
+ * ScrollTriggers and the `matchMedia` above are both reverted with the
117
+ * context — each registers itself when constructed inside one. Only
118
+ * things GSAP never sees (timers, raw listeners, observers) need a
119
+ * cleanup returned from here.
120
+ */
121
+ },
122
+ { scope: rootRef },
123
+ );
124
+
125
+ return rootRef;
126
+ }
@@ -1,110 +1,110 @@
1
- "use client";
2
-
3
- import { useGSAP } from "@gsap/react";
4
- import gsap from "gsap";
5
- import { useCallback, useRef } from "react";
6
-
7
- /**
8
- * Starting point for a component-scoped GSAP timeline.
9
- *
10
- * Copy it into the project's hooks folder or next to the component that owns
11
- * it, rename it, and replace the timeline body. The structure is the part worth
12
- * keeping: plugin registration at module scope, a scoped `useGSAP`, a
13
- * `matchMedia` branch that treats reduced motion as a design rather than an off
14
- * switch, and an explicit teardown for the things the GSAP context does not
15
- * own.
16
- *
17
- * Worth typechecking where the skill is installed: add the skill's `template/`
18
- * folder to `tsconfig.json`'s `include`. TypeScript's globs skip dot-directories
19
- * such as `.claude/`, and an example that has quietly stopped compiling is worse
20
- * than none.
21
- */
22
- gsap.registerPlugin(useGSAP);
23
-
24
- /** One place for the numbers, so retiming is not a hunt through the body. */
25
- const TIMING = {
26
- enter: 0.8,
27
- stagger: 0.08,
28
- hold: 0.4,
29
- } as const;
30
-
31
- export function useTimeline() {
32
- const rootRef = useRef<HTMLDivElement>(null);
33
-
34
- /**
35
- * Kept on a ref rather than returned directly: the timeline is built inside
36
- * the effect, and a caller that wants to replay it needs a stable handle that
37
- * does not exist until then.
38
- */
39
- const timelineRef = useRef<gsap.core.Timeline | null>(null);
40
-
41
- useGSAP(
42
- () => {
43
- /**
44
- * `matchMedia` rather than a one-off query, because a visitor can change
45
- * the preference or the viewport while the component is mounted. GSAP
46
- * reverts the losing branch and builds the other one; a plain `if` leaves
47
- * the first animation running forever.
48
- */
49
- const mm = gsap.matchMedia();
50
-
51
- mm.add(
52
- {
53
- motion: "(prefers-reduced-motion: no-preference)",
54
- reduced: "(prefers-reduced-motion: reduce)",
55
- },
56
- (context) => {
57
- const { reduced } = context.conditions as Record<string, boolean>;
58
-
59
- /**
60
- * The reduced branch must set the END state, not merely skip the
61
- * tween. A `from` that never runs leaves its target at the authored
62
- * start — which hides the content rather than calming it. This is the
63
- * most common reduced-motion bug there is.
64
- */
65
- if (reduced) {
66
- gsap.set("[data-item]", { autoAlpha: 1, y: 0 });
67
- return;
68
- }
69
-
70
- timelineRef.current = gsap
71
- .timeline({ defaults: { ease: "power3.out" } })
72
- .from("[data-item]", {
73
- y: 32,
74
- autoAlpha: 0,
75
- duration: TIMING.enter,
76
- stagger: TIMING.stagger,
77
- })
78
- /**
79
- * Positions, not delays. `<` starts a child with the previous one
80
- * and survives a duration change upstream, where `-=0.3` silently
81
- * drifts.
82
- */
83
- .to("[data-accent]", { scaleX: 1, duration: 0.5 }, "<0.2");
84
- },
85
- );
86
-
87
- /**
88
- * Nothing is returned, and that is deliberate. `matchMedia` registers
89
- * itself with whatever GSAP context is active when it is constructed, so
90
- * this one is reverted along with every tween above — as are Observer,
91
- * Draggable, SplitText and ScrollTrigger when built in here.
92
- *
93
- * What a context never sees is the rest: timers, raw `addEventListener`,
94
- * ResizeObserver, Three.js disposal. Return a cleanup for those.
95
- */
96
- },
97
- { scope: rootRef },
98
- );
99
-
100
- /**
101
- * Playing a timeline that already exists creates nothing, so this needs no
102
- * `contextSafe` — the context already owns it. Wrapping it would also be a
103
- * lint error, because `contextSafe` runs during render and reading a ref
104
- * there is what React forbids. Reserve `contextSafe` for a handler that
105
- * *creates* a tween.
106
- */
107
- const replay = useCallback(() => timelineRef.current?.restart(), []);
108
-
109
- return { rootRef, replay };
110
- }
1
+ "use client";
2
+
3
+ import { useGSAP } from "@gsap/react";
4
+ import gsap from "gsap";
5
+ import { useCallback, useRef } from "react";
6
+
7
+ /**
8
+ * Starting point for a component-scoped GSAP timeline.
9
+ *
10
+ * Copy it into the project's hooks folder or next to the component that owns
11
+ * it, rename it, and replace the timeline body. The structure is the part worth
12
+ * keeping: plugin registration at module scope, a scoped `useGSAP`, a
13
+ * `matchMedia` branch that treats reduced motion as a design rather than an off
14
+ * switch, and an explicit teardown for the things the GSAP context does not
15
+ * own.
16
+ *
17
+ * Worth typechecking where the skill is installed: add the skill's `template/`
18
+ * folder to `tsconfig.json`'s `include`. TypeScript's globs skip dot-directories
19
+ * such as `.claude/`, and an example that has quietly stopped compiling is worse
20
+ * than none.
21
+ */
22
+ gsap.registerPlugin(useGSAP);
23
+
24
+ /** One place for the numbers, so retiming is not a hunt through the body. */
25
+ const TIMING = {
26
+ enter: 0.8,
27
+ stagger: 0.08,
28
+ hold: 0.4,
29
+ } as const;
30
+
31
+ export function useTimeline() {
32
+ const rootRef = useRef<HTMLDivElement>(null);
33
+
34
+ /**
35
+ * Kept on a ref rather than returned directly: the timeline is built inside
36
+ * the effect, and a caller that wants to replay it needs a stable handle that
37
+ * does not exist until then.
38
+ */
39
+ const timelineRef = useRef<gsap.core.Timeline | null>(null);
40
+
41
+ useGSAP(
42
+ () => {
43
+ /**
44
+ * `matchMedia` rather than a one-off query, because a visitor can change
45
+ * the preference or the viewport while the component is mounted. GSAP
46
+ * reverts the losing branch and builds the other one; a plain `if` leaves
47
+ * the first animation running forever.
48
+ */
49
+ const mm = gsap.matchMedia();
50
+
51
+ mm.add(
52
+ {
53
+ motion: "(prefers-reduced-motion: no-preference)",
54
+ reduced: "(prefers-reduced-motion: reduce)",
55
+ },
56
+ (context) => {
57
+ const { reduced } = context.conditions as Record<string, boolean>;
58
+
59
+ /**
60
+ * The reduced branch must set the END state, not merely skip the
61
+ * tween. A `from` that never runs leaves its target at the authored
62
+ * start — which hides the content rather than calming it. This is the
63
+ * most common reduced-motion bug there is.
64
+ */
65
+ if (reduced) {
66
+ gsap.set("[data-item]", { autoAlpha: 1, y: 0 });
67
+ return;
68
+ }
69
+
70
+ timelineRef.current = gsap
71
+ .timeline({ defaults: { ease: "power3.out" } })
72
+ .from("[data-item]", {
73
+ y: 32,
74
+ autoAlpha: 0,
75
+ duration: TIMING.enter,
76
+ stagger: TIMING.stagger,
77
+ })
78
+ /**
79
+ * Positions, not delays. `<` starts a child with the previous one
80
+ * and survives a duration change upstream, where `-=0.3` silently
81
+ * drifts.
82
+ */
83
+ .to("[data-accent]", { scaleX: 1, duration: 0.5 }, "<0.2");
84
+ },
85
+ );
86
+
87
+ /**
88
+ * Nothing is returned, and that is deliberate. `matchMedia` registers
89
+ * itself with whatever GSAP context is active when it is constructed, so
90
+ * this one is reverted along with every tween above — as are Observer,
91
+ * Draggable, SplitText and ScrollTrigger when built in here.
92
+ *
93
+ * What a context never sees is the rest: timers, raw `addEventListener`,
94
+ * ResizeObserver, Three.js disposal. Return a cleanup for those.
95
+ */
96
+ },
97
+ { scope: rootRef },
98
+ );
99
+
100
+ /**
101
+ * Playing a timeline that already exists creates nothing, so this needs no
102
+ * `contextSafe` — the context already owns it. Wrapping it would also be a
103
+ * lint error, because `contextSafe` runs during render and reading a ref
104
+ * there is what React forbids. Reserve `contextSafe` for a handler that
105
+ * *creates* a tween.
106
+ */
107
+ const replay = useCallback(() => timelineRef.current?.restart(), []);
108
+
109
+ return { rootRef, replay };
110
+ }