@vysmo/scroll 0.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/README.md +110 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/observer.d.ts +36 -0
- package/dist/observer.d.ts.map +1 -0
- package/dist/observer.js +82 -0
- package/dist/observer.js.map +1 -0
- package/dist/progress.d.ts +14 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +36 -0
- package/dist/progress.js.map +1 -0
- package/dist/scroll-effect.d.ts +37 -0
- package/dist/scroll-effect.d.ts.map +1 -0
- package/dist/scroll-effect.js +39 -0
- package/dist/scroll-effect.js.map +1 -0
- package/dist/scroll-transition.d.ts +38 -0
- package/dist/scroll-transition.d.ts.map +1 -0
- package/dist/scroll-transition.js +42 -0
- package/dist/scroll-transition.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zones.d.ts +56 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +94 -0
- package/dist/zones.js.map +1 -0
- package/package.json +52 -0
- package/src/__tests__/__screenshots__/progress.test.ts/createScrollProgress-reaches-1-after-fully-scrolling-past-the-element-1.png +0 -0
- package/src/__tests__/observer.test.ts +60 -0
- package/src/__tests__/progress.test.ts +126 -0
- package/src/__tests__/scroll-effect.test.ts +135 -0
- package/src/__tests__/scroll-transition.test.ts +167 -0
- package/src/__tests__/ssr.test.ts +37 -0
- package/src/__tests__/types-check.ts +86 -0
- package/src/__tests__/zones.test.ts +175 -0
- package/src/index.ts +9 -0
- package/src/observer.ts +89 -0
- package/src/progress.ts +39 -0
- package/src/scroll-effect.ts +72 -0
- package/src/scroll-transition.ts +80 -0
- package/src/types.ts +19 -0
- package/src/zones.ts +103 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createScrollEffect,
|
|
3
|
+
createScrollProgress,
|
|
4
|
+
createScrollTransition,
|
|
5
|
+
sharedScrollObserver,
|
|
6
|
+
type Handle,
|
|
7
|
+
} from "../index.js";
|
|
8
|
+
import type { Runner as TransitionRunner, Transition, TextureSource } from "@vysmo/transitions";
|
|
9
|
+
import type { Runner as EffectRunner, Effect } from "@vysmo/effects";
|
|
10
|
+
|
|
11
|
+
declare const el: HTMLElement;
|
|
12
|
+
declare const section: HTMLElement;
|
|
13
|
+
declare const img1: TextureSource;
|
|
14
|
+
declare const img2: TextureSource;
|
|
15
|
+
declare const tRunner: TransitionRunner;
|
|
16
|
+
declare const eRunner: EffectRunner;
|
|
17
|
+
declare const myTransition: Transition<{ amount: number }>;
|
|
18
|
+
declare const myEffect: Effect<{ radius: number }>;
|
|
19
|
+
|
|
20
|
+
const _progress: Handle = createScrollProgress({
|
|
21
|
+
element: el,
|
|
22
|
+
onProgress: (p) => void p,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const _progressEased: Handle = createScrollProgress({
|
|
26
|
+
element: el,
|
|
27
|
+
ease: (t) => t * t,
|
|
28
|
+
onProgress: () => {},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const _scrollTrans: Handle = createScrollTransition({
|
|
32
|
+
section,
|
|
33
|
+
runner: tRunner,
|
|
34
|
+
transition: myTransition,
|
|
35
|
+
from: img1,
|
|
36
|
+
to: img2,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const _scrollTransFull: Handle = createScrollTransition({
|
|
40
|
+
section,
|
|
41
|
+
runner: tRunner,
|
|
42
|
+
transition: myTransition,
|
|
43
|
+
from: img1,
|
|
44
|
+
to: img2,
|
|
45
|
+
params: { amount: 0.5 },
|
|
46
|
+
ease: (t) => t * t,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const _scrollEffect: Handle = createScrollEffect({
|
|
50
|
+
section,
|
|
51
|
+
runner: eRunner,
|
|
52
|
+
effect: myEffect,
|
|
53
|
+
source: img1,
|
|
54
|
+
paramsAt: (p) => ({ radius: p * 20 }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const _scrollEffectEased: Handle = createScrollEffect({
|
|
58
|
+
section,
|
|
59
|
+
runner: eRunner,
|
|
60
|
+
effect: myEffect,
|
|
61
|
+
source: img1,
|
|
62
|
+
ease: (t) => 1 - (1 - t) * (1 - t),
|
|
63
|
+
paramsAt: (p) => ({ radius: p * 20 }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const _obs = sharedScrollObserver();
|
|
67
|
+
|
|
68
|
+
void [_progress, _progressEased, _scrollTrans, _scrollTransFull, _scrollEffect, _scrollEffectEased, _obs];
|
|
69
|
+
|
|
70
|
+
// --- Negative assertions ------------------------------------------------
|
|
71
|
+
|
|
72
|
+
// @ts-expect-error — onProgress is required
|
|
73
|
+
createScrollProgress({ element: el });
|
|
74
|
+
|
|
75
|
+
// @ts-expect-error — paramsAt is required
|
|
76
|
+
createScrollEffect({ section, runner: eRunner, effect: myEffect, source: img1 });
|
|
77
|
+
|
|
78
|
+
createScrollTransition({
|
|
79
|
+
section,
|
|
80
|
+
runner: tRunner,
|
|
81
|
+
transition: myTransition,
|
|
82
|
+
from: img1,
|
|
83
|
+
to: img2,
|
|
84
|
+
// @ts-expect-error — params shape must match the transition's param type
|
|
85
|
+
params: { wrongKey: 1 },
|
|
86
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
scrollPlateau,
|
|
4
|
+
scrollRange,
|
|
5
|
+
scrollZones,
|
|
6
|
+
smoothstep,
|
|
7
|
+
} from "../zones.js";
|
|
8
|
+
|
|
9
|
+
const linear = (t: number) => t;
|
|
10
|
+
|
|
11
|
+
describe("smoothstep", () => {
|
|
12
|
+
it("matches linear at 0, 0.5, 1 (useful for tests that assert endpoints)", () => {
|
|
13
|
+
expect(smoothstep(0)).toBe(0);
|
|
14
|
+
expect(smoothstep(0.5)).toBeCloseTo(0.5, 5);
|
|
15
|
+
expect(smoothstep(1)).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("eases in and out — below linear near 0, above near 1", () => {
|
|
19
|
+
expect(smoothstep(0.25)).toBeCloseTo(0.15625, 5); // 0.25² · (3 − 0.5)
|
|
20
|
+
expect(smoothstep(0.75)).toBeCloseTo(0.84375, 5); // symmetric complement
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("scrollRange", () => {
|
|
25
|
+
it("returns 0 before start, 1 after end, smoothstep in between", () => {
|
|
26
|
+
const r = scrollRange(0.1, 0.5);
|
|
27
|
+
expect(r(0)).toBe(0);
|
|
28
|
+
expect(r(0.1)).toBe(0);
|
|
29
|
+
expect(r(0.3)).toBeCloseTo(0.5, 5); // smoothstep(0.5) = 0.5
|
|
30
|
+
expect(r(0.5)).toBe(1);
|
|
31
|
+
expect(r(0.9)).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("start == end collapses to a step", () => {
|
|
35
|
+
const r = scrollRange(0.5, 0.5);
|
|
36
|
+
expect(r(0.49)).toBe(0);
|
|
37
|
+
expect(r(0.5)).toBe(1);
|
|
38
|
+
expect(r(0.6)).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("end < start is treated as a step", () => {
|
|
42
|
+
const r = scrollRange(0.8, 0.2);
|
|
43
|
+
expect(r(0.5)).toBe(0);
|
|
44
|
+
expect(r(0.9)).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("linear ease yields the identity over [0, 1]", () => {
|
|
48
|
+
const r = scrollRange(0, 1, linear);
|
|
49
|
+
expect(r(0)).toBe(0);
|
|
50
|
+
expect(r(0.25)).toBeCloseTo(0.25, 5);
|
|
51
|
+
expect(r(0.5)).toBeCloseTo(0.5, 5);
|
|
52
|
+
expect(r(1)).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts a custom ease to shape the ramp", () => {
|
|
56
|
+
const r = scrollRange(0, 1, (t) => t * t);
|
|
57
|
+
expect(r(0.5)).toBeCloseTo(0.25, 5);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("scrollZones", () => {
|
|
62
|
+
it("returns 0 inside the clear zone", () => {
|
|
63
|
+
const z = scrollZones(0.25, 0.85);
|
|
64
|
+
expect(z(0.25)).toBe(0);
|
|
65
|
+
expect(z(0.5)).toBe(0);
|
|
66
|
+
expect(z(0.85)).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ramps from 1 to 0 through the entry zone", () => {
|
|
70
|
+
const z = scrollZones(0.2, 0.8);
|
|
71
|
+
expect(z(0)).toBe(1);
|
|
72
|
+
expect(z(0.1)).toBeCloseTo(0.5, 5);
|
|
73
|
+
expect(z(0.2)).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ramps from 0 to 1 through the exit zone", () => {
|
|
77
|
+
const z = scrollZones(0.2, 0.8);
|
|
78
|
+
expect(z(0.8)).toBe(0);
|
|
79
|
+
expect(z(0.9)).toBeCloseTo(0.5, 5);
|
|
80
|
+
expect(z(1)).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("is symmetric around the midpoint when the clear zone is centered", () => {
|
|
84
|
+
const z = scrollZones(0.3, 0.7);
|
|
85
|
+
for (const p of [0, 0.1, 0.15, 0.3, 0.7, 0.85, 0.9, 1]) {
|
|
86
|
+
expect(z(p)).toBeCloseTo(z(1 - p), 5);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("clear zone spanning the whole range yields zero everywhere", () => {
|
|
91
|
+
const z = scrollZones(0, 1);
|
|
92
|
+
for (const p of [0, 0.25, 0.5, 0.75, 1]) {
|
|
93
|
+
expect(z(p)).toBe(0);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("clearStart at 0 collapses the entry ramp to a step at the zone edge", () => {
|
|
98
|
+
const z = scrollZones(0, 0.5);
|
|
99
|
+
expect(z(0)).toBe(0);
|
|
100
|
+
expect(z(0.3)).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("clearEnd at 1 collapses the exit ramp to a step at the zone edge", () => {
|
|
104
|
+
const z = scrollZones(0.5, 1);
|
|
105
|
+
expect(z(0.75)).toBe(0);
|
|
106
|
+
expect(z(1)).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("default ramp uses smoothstep — softer than linear near boundaries", () => {
|
|
110
|
+
const z = scrollZones(0.2, 0.8);
|
|
111
|
+
// At local_t = 0.25 of the entry ramp (p = 0.05), smoothstep yields
|
|
112
|
+
// 0.15625, so the output (1 − smoothstep) ≈ 0.844, vs linear's 0.75.
|
|
113
|
+
expect(z(0.05)).toBeCloseTo(0.84375, 5);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("accepts a custom ease to override the default smoothstep", () => {
|
|
117
|
+
const z = scrollZones(0.2, 0.8, linear);
|
|
118
|
+
expect(z(0.1)).toBeCloseTo(0.5, 5); // linear midpoint of ramp
|
|
119
|
+
expect(z(0.05)).toBeCloseTo(0.75, 5); // linear at local_t = 0.25
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("scrollPlateau", () => {
|
|
124
|
+
it("returns 1 inside the clear zone", () => {
|
|
125
|
+
const z = scrollPlateau(0.3, 0.7);
|
|
126
|
+
expect(z(0.3)).toBe(1);
|
|
127
|
+
expect(z(0.5)).toBe(1);
|
|
128
|
+
expect(z(0.7)).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("ramps from 0 to 1 through the entry zone", () => {
|
|
132
|
+
const z = scrollPlateau(0.2, 0.8);
|
|
133
|
+
expect(z(0)).toBe(0);
|
|
134
|
+
expect(z(0.1)).toBeCloseTo(0.5, 5);
|
|
135
|
+
expect(z(0.2)).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("ramps from 1 to 0 through the exit zone", () => {
|
|
139
|
+
const z = scrollPlateau(0.2, 0.8);
|
|
140
|
+
expect(z(0.8)).toBe(1);
|
|
141
|
+
expect(z(0.9)).toBeCloseTo(0.5, 5);
|
|
142
|
+
expect(z(1)).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("is symmetric around the midpoint when the clear zone is centered", () => {
|
|
146
|
+
const z = scrollPlateau(0.3, 0.7);
|
|
147
|
+
for (const p of [0, 0.1, 0.15, 0.3, 0.7, 0.85, 0.9, 1]) {
|
|
148
|
+
expect(z(p)).toBeCloseTo(z(1 - p), 5);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("is 1 − scrollZones at every sample point (by construction)", () => {
|
|
153
|
+
const a = scrollPlateau(0.3, 0.7);
|
|
154
|
+
const b = scrollZones(0.3, 0.7);
|
|
155
|
+
for (let i = 0; i <= 10; i++) {
|
|
156
|
+
const p = i / 10;
|
|
157
|
+
expect(a(p) + b(p)).toBeCloseTo(1, 5);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("default smoothstep produces a C1 approach to the plateau — no harsh snap", () => {
|
|
162
|
+
const z = scrollPlateau(0.2, 0.8);
|
|
163
|
+
// Just before the plateau starts: the output should have eased close
|
|
164
|
+
// to 1 rather than still climbing linearly. At p = 0.18, local_t = 0.9.
|
|
165
|
+
// smoothstep(0.9) = 0.972; linear would give 0.9.
|
|
166
|
+
expect(z(0.18)).toBeCloseTo(0.972, 3);
|
|
167
|
+
expect(z(0.82)).toBeCloseTo(0.972, 3); // symmetric on the exit side
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("accepts a custom ease to override the default smoothstep", () => {
|
|
171
|
+
const z = scrollPlateau(0.2, 0.8, linear);
|
|
172
|
+
expect(z(0.1)).toBeCloseTo(0.5, 5);
|
|
173
|
+
expect(z(0.18)).toBeCloseTo(0.9, 5);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createScrollProgress } from "./progress.js";
|
|
2
|
+
export { createScrollTransition } from "./scroll-transition.js";
|
|
3
|
+
export type { ScrollTransitionOptions } from "./scroll-transition.js";
|
|
4
|
+
export { createScrollEffect } from "./scroll-effect.js";
|
|
5
|
+
export type { ScrollEffectOptions } from "./scroll-effect.js";
|
|
6
|
+
export { sharedScrollObserver, ScrollObserver } from "./observer.js";
|
|
7
|
+
export type { ScrollSubscriber } from "./observer.js";
|
|
8
|
+
export { scrollPlateau, scrollRange, scrollZones, smoothstep } from "./zones.js";
|
|
9
|
+
export type { EaseFn, Handle, ScrollProgressOptions } from "./types.js";
|
package/src/observer.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rAF-throttled scroll / resize observer. Every primitive in this
|
|
3
|
+
* package (progress, parallax, sticky-pin, horizontal-section) registers
|
|
4
|
+
* its element here; the observer keeps one passive `scroll` listener on
|
|
5
|
+
* `window` for the lifetime of any subscription and tears it down once
|
|
6
|
+
* the last subscriber unsubscribes.
|
|
7
|
+
*
|
|
8
|
+
* Safe to import at module load: no window access happens until
|
|
9
|
+
* `subscribe()` is called.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ScrollSubscriber {
|
|
13
|
+
onScroll(
|
|
14
|
+
rect: DOMRect,
|
|
15
|
+
viewport: { readonly width: number; readonly height: number },
|
|
16
|
+
): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ScrollObserver {
|
|
20
|
+
private subs = new Map<HTMLElement, ScrollSubscriber>();
|
|
21
|
+
private listening = false;
|
|
22
|
+
private rafId: number | null = null;
|
|
23
|
+
private readonly scheduler: () => void;
|
|
24
|
+
|
|
25
|
+
constructor() {
|
|
26
|
+
this.scheduler = (): void => this.scheduleUpdate();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
subscribe(element: HTMLElement, subscriber: ScrollSubscriber): () => void {
|
|
30
|
+
this.subs.set(element, subscriber);
|
|
31
|
+
if (!this.listening) this.start();
|
|
32
|
+
this.scheduleUpdate();
|
|
33
|
+
return () => {
|
|
34
|
+
this.subs.delete(element);
|
|
35
|
+
if (this.subs.size === 0) this.stop();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private start(): void {
|
|
40
|
+
if (typeof window === "undefined") return;
|
|
41
|
+
window.addEventListener("scroll", this.scheduler, { passive: true });
|
|
42
|
+
window.addEventListener("resize", this.scheduler, { passive: true });
|
|
43
|
+
this.listening = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private stop(): void {
|
|
47
|
+
if (typeof window === "undefined") return;
|
|
48
|
+
window.removeEventListener("scroll", this.scheduler);
|
|
49
|
+
window.removeEventListener("resize", this.scheduler);
|
|
50
|
+
if (this.rafId !== null) {
|
|
51
|
+
cancelAnimationFrame(this.rafId);
|
|
52
|
+
this.rafId = null;
|
|
53
|
+
}
|
|
54
|
+
this.listening = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private scheduleUpdate(): void {
|
|
58
|
+
if (this.rafId !== null) return;
|
|
59
|
+
if (typeof requestAnimationFrame === "undefined") return;
|
|
60
|
+
this.rafId = requestAnimationFrame(() => {
|
|
61
|
+
this.rafId = null;
|
|
62
|
+
this.flush();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run every subscriber's callback with the current viewport + element
|
|
68
|
+
* rect. Exposed for tests that need deterministic flushes without
|
|
69
|
+
* waiting on rAF.
|
|
70
|
+
*/
|
|
71
|
+
flush(): void {
|
|
72
|
+
if (typeof window === "undefined") return;
|
|
73
|
+
const viewport = {
|
|
74
|
+
width: window.innerWidth,
|
|
75
|
+
height: window.innerHeight,
|
|
76
|
+
} as const;
|
|
77
|
+
for (const [el, sub] of this.subs) {
|
|
78
|
+
sub.onScroll(el.getBoundingClientRect(), viewport);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let _shared: ScrollObserver | null = null;
|
|
84
|
+
|
|
85
|
+
/** Lazy singleton — first call creates the observer. SSR-safe. */
|
|
86
|
+
export function sharedScrollObserver(): ScrollObserver {
|
|
87
|
+
if (!_shared) _shared = new ScrollObserver();
|
|
88
|
+
return _shared;
|
|
89
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sharedScrollObserver } from "./observer.js";
|
|
2
|
+
import type { Handle, ScrollProgressOptions } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Emits a continuous [0, 1] value as `element` sweeps across the viewport.
|
|
6
|
+
*
|
|
7
|
+
* progress = 0 → element's top edge is at the viewport's bottom edge
|
|
8
|
+
* (element has just entered from below)
|
|
9
|
+
* progress = 1 → element's bottom edge is at the viewport's top edge
|
|
10
|
+
* (element has just exited through the top)
|
|
11
|
+
*
|
|
12
|
+
* The curve is linear by default; pass `ease: (t) => ...` to remap —
|
|
13
|
+
* any easing from `@vysmo/easings` works without importing it here.
|
|
14
|
+
*/
|
|
15
|
+
export function createScrollProgress(
|
|
16
|
+
options: ScrollProgressOptions,
|
|
17
|
+
): Handle {
|
|
18
|
+
const observer = sharedScrollObserver();
|
|
19
|
+
let lastProgress = Number.NaN;
|
|
20
|
+
|
|
21
|
+
const unsubscribe = observer.subscribe(options.element, {
|
|
22
|
+
onScroll(rect, viewport) {
|
|
23
|
+
const span = viewport.height + rect.height;
|
|
24
|
+
if (span <= 0) return;
|
|
25
|
+
const raw = (viewport.height - rect.top) / span;
|
|
26
|
+
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
27
|
+
const mapped = options.ease ? options.ease(clamped) : clamped;
|
|
28
|
+
if (mapped === lastProgress) return;
|
|
29
|
+
lastProgress = mapped;
|
|
30
|
+
options.onProgress(mapped);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
destroy(): void {
|
|
36
|
+
unsubscribe();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Effect,
|
|
3
|
+
Runner,
|
|
4
|
+
TextureSource,
|
|
5
|
+
UniformParams,
|
|
6
|
+
} from "@vysmo/effects";
|
|
7
|
+
import { sharedScrollObserver } from "./observer.js";
|
|
8
|
+
import type { EaseFn, Handle } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface ScrollEffectOptions<P extends UniformParams> {
|
|
11
|
+
/**
|
|
12
|
+
* Section whose scroll-past drives the effect. Progress spans the
|
|
13
|
+
* full viewport sweep, same as `createScrollProgress`.
|
|
14
|
+
*/
|
|
15
|
+
section: HTMLElement;
|
|
16
|
+
/** Effects runner; caller owns the canvas + WebGL context. */
|
|
17
|
+
runner: Runner;
|
|
18
|
+
/** Effect to render on every scroll frame. */
|
|
19
|
+
effect: Effect<P>;
|
|
20
|
+
/** Source texture to filter — image, video, canvas. */
|
|
21
|
+
source: TextureSource;
|
|
22
|
+
/**
|
|
23
|
+
* Maps the scroll progress [0, 1] to the effect's uniform params.
|
|
24
|
+
* Keeps the scroll package free of effect-specific knowledge — the
|
|
25
|
+
* caller decides which param to animate and how.
|
|
26
|
+
*
|
|
27
|
+
* paramsAt: (p) => ({ radius: p * 20 })
|
|
28
|
+
*/
|
|
29
|
+
paramsAt: (progress: number) => Partial<P>;
|
|
30
|
+
/** Remap the raw [0, 1] progress before it reaches `paramsAt`. */
|
|
31
|
+
ease?: EaseFn;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bind the scroll position through `section` to a continuous render of
|
|
36
|
+
* `effect` on `runner`, where the effect's params are a function of
|
|
37
|
+
* progress. Typical use: blur / chromatic-aberration / colour-grade
|
|
38
|
+
* intensity ramps up as the user scrolls into a section and back down
|
|
39
|
+
* as they scroll out.
|
|
40
|
+
*
|
|
41
|
+
* Same ownership model as `createScrollTransition`: you pass a Runner,
|
|
42
|
+
* scroll drives its `render()`. No re-render when progress is unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function createScrollEffect<P extends UniformParams>(
|
|
45
|
+
options: ScrollEffectOptions<P>,
|
|
46
|
+
): Handle {
|
|
47
|
+
const observer = sharedScrollObserver();
|
|
48
|
+
let lastProgress = Number.NaN;
|
|
49
|
+
|
|
50
|
+
const unsubscribe = observer.subscribe(options.section, {
|
|
51
|
+
onScroll(rect, viewport) {
|
|
52
|
+
const span = viewport.height + rect.height;
|
|
53
|
+
if (span <= 0) return;
|
|
54
|
+
const raw = (viewport.height - rect.top) / span;
|
|
55
|
+
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
56
|
+
const mapped = options.ease ? options.ease(clamped) : clamped;
|
|
57
|
+
if (mapped === lastProgress) return;
|
|
58
|
+
lastProgress = mapped;
|
|
59
|
+
const params = options.paramsAt(mapped);
|
|
60
|
+
options.runner.render(options.effect, {
|
|
61
|
+
source: options.source,
|
|
62
|
+
params,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
destroy(): void {
|
|
69
|
+
unsubscribe();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Runner,
|
|
3
|
+
TextureSource,
|
|
4
|
+
Transition,
|
|
5
|
+
UniformParams,
|
|
6
|
+
} from "@vysmo/transitions";
|
|
7
|
+
import { sharedScrollObserver } from "./observer.js";
|
|
8
|
+
import type { EaseFn, Handle } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface ScrollTransitionOptions<P extends UniformParams> {
|
|
11
|
+
/**
|
|
12
|
+
* Section whose scroll-past drives the transition. Progress is 0 when
|
|
13
|
+
* the section's top enters the viewport bottom and 1 when its bottom
|
|
14
|
+
* exits the viewport top — same curve as `createScrollProgress`.
|
|
15
|
+
*/
|
|
16
|
+
section: HTMLElement;
|
|
17
|
+
/**
|
|
18
|
+
* Transitions runner. The caller owns the WebGL context and its
|
|
19
|
+
* canvas — that keeps this primitive composable (multiple
|
|
20
|
+
* scroll-transitions can share one runner) and decouples lifecycle.
|
|
21
|
+
*/
|
|
22
|
+
runner: Runner;
|
|
23
|
+
/** Transition to render across the scroll range. */
|
|
24
|
+
transition: Transition<P>;
|
|
25
|
+
/** Starting source — shown at progress 0. */
|
|
26
|
+
from: TextureSource;
|
|
27
|
+
/** Ending source — shown at progress 1. */
|
|
28
|
+
to: TextureSource;
|
|
29
|
+
/** Overrides for the transition's uniform params. */
|
|
30
|
+
params?: Partial<P>;
|
|
31
|
+
/** Remap the raw [0, 1] progress. Default: linear. */
|
|
32
|
+
ease?: EaseFn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bind the vertical scroll position through `section` to a full transition
|
|
37
|
+
* run on `runner`. As the section sweeps past the viewport, `from` morphs
|
|
38
|
+
* into `to` via the chosen transition. One rAF per scroll frame, no
|
|
39
|
+
* re-render when the clamped progress hasn't changed.
|
|
40
|
+
*
|
|
41
|
+
* The scroll package does not import the transitions runtime — only its
|
|
42
|
+
* types. You pass in your own `Runner`, so the transitions library only
|
|
43
|
+
* lands in bundles that actually call this primitive.
|
|
44
|
+
*/
|
|
45
|
+
export function createScrollTransition<P extends UniformParams>(
|
|
46
|
+
options: ScrollTransitionOptions<P>,
|
|
47
|
+
): Handle {
|
|
48
|
+
const observer = sharedScrollObserver();
|
|
49
|
+
let lastProgress = Number.NaN;
|
|
50
|
+
|
|
51
|
+
const unsubscribe = observer.subscribe(options.section, {
|
|
52
|
+
onScroll(rect, viewport) {
|
|
53
|
+
const span = viewport.height + rect.height;
|
|
54
|
+
if (span <= 0) return;
|
|
55
|
+
const raw = (viewport.height - rect.top) / span;
|
|
56
|
+
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
57
|
+
const mapped = options.ease ? options.ease(clamped) : clamped;
|
|
58
|
+
if (mapped === lastProgress) return;
|
|
59
|
+
lastProgress = mapped;
|
|
60
|
+
const args: {
|
|
61
|
+
from: TextureSource;
|
|
62
|
+
to: TextureSource;
|
|
63
|
+
progress: number;
|
|
64
|
+
params?: Partial<P>;
|
|
65
|
+
} = {
|
|
66
|
+
from: options.from,
|
|
67
|
+
to: options.to,
|
|
68
|
+
progress: mapped,
|
|
69
|
+
};
|
|
70
|
+
if (options.params !== undefined) args.params = options.params;
|
|
71
|
+
options.runner.render(options.transition, args);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
destroy(): void {
|
|
77
|
+
unsubscribe();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Signature compatible with `@vysmo/easings.EasingFn`, kept local so this package has zero runtime coupling to the easings lib. */
|
|
2
|
+
export type EaseFn = (t: number) => number;
|
|
3
|
+
|
|
4
|
+
export interface Handle {
|
|
5
|
+
destroy(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScrollProgressOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Element whose bounding box is tracked. Progress is 0 when its top is
|
|
11
|
+
* at the viewport bottom and 1 when its bottom is at the viewport top
|
|
12
|
+
* — i.e. a full sweep across the viewport.
|
|
13
|
+
*/
|
|
14
|
+
element: HTMLElement;
|
|
15
|
+
/** Remap the raw [0, 1] progress. Default: linear. */
|
|
16
|
+
ease?: EaseFn;
|
|
17
|
+
/** Invoked on every frame the element's position changes. */
|
|
18
|
+
onProgress: (progress: number) => void;
|
|
19
|
+
}
|
package/src/zones.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { EaseFn } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Smoothstep — `t² · (3 − 2t)`. C1-continuous S-curve that matches
|
|
5
|
+
* linear at 0, 0.5, and 1 but eases in and out near the boundaries.
|
|
6
|
+
* Used as the default ramp shape for every zone helper so transitions
|
|
7
|
+
* and effects decelerate smoothly into their plateaus instead of
|
|
8
|
+
* snapping on the derivative discontinuity of a pure linear ramp.
|
|
9
|
+
*
|
|
10
|
+
* Exported so callers can reach for it explicitly, or compose it.
|
|
11
|
+
*/
|
|
12
|
+
export const smoothstep: EaseFn = (t) => t * t * (3 - 2 * t);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Clamp progress to a sub-range. Outside the range, the output stays flat
|
|
16
|
+
* (0 before `start`, 1 after `end`). Inside, progress is remapped from
|
|
17
|
+
* `[start, end]` to `[0, 1]` through the supplied ease (default:
|
|
18
|
+
* {@link smoothstep}).
|
|
19
|
+
*
|
|
20
|
+
* scrollRange(0.1, 0.5) — transition plays between 10% and 50% of the
|
|
21
|
+
* full viewport sweep; does nothing before, stays at its final state
|
|
22
|
+
* after. Pass `ease: (t) => t` for a linear clamp.
|
|
23
|
+
*
|
|
24
|
+
* Designed for `createScrollTransition` so the transition completes at a
|
|
25
|
+
* point the author chooses — typically well before the section exits the
|
|
26
|
+
* viewport — and then holds its final state while the user keeps scrolling.
|
|
27
|
+
*/
|
|
28
|
+
export function scrollRange(
|
|
29
|
+
start: number,
|
|
30
|
+
end: number,
|
|
31
|
+
ease: EaseFn = smoothstep,
|
|
32
|
+
): EaseFn {
|
|
33
|
+
if (end <= start) return (p) => (p < start ? 0 : 1);
|
|
34
|
+
const span = end - start;
|
|
35
|
+
return (p) => {
|
|
36
|
+
if (p <= start) return 0;
|
|
37
|
+
if (p >= end) return 1;
|
|
38
|
+
return ease((p - start) / span);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Three-zone bathtub envelope for effects.
|
|
44
|
+
*
|
|
45
|
+
* scrollZones(0.25, 0.85) — effect ramps from max at `p = 0` down to
|
|
46
|
+
* zero at `p = 0.25` (smoothly by default), stays at zero through the
|
|
47
|
+
* clear zone (`0.25 ≤ p ≤ 0.85`), then ramps back up to max by
|
|
48
|
+
* `p = 1`.
|
|
49
|
+
*
|
|
50
|
+
* Designed for `createScrollEffect` where "identity" is zero intensity —
|
|
51
|
+
* so the image reads cleanly while the section is visible and the effect
|
|
52
|
+
* only appears as it enters and exits the viewport. Pass `ease: (t) => t`
|
|
53
|
+
* for a linear ramp; default is {@link smoothstep}.
|
|
54
|
+
*/
|
|
55
|
+
export function scrollZones(
|
|
56
|
+
clearStart: number,
|
|
57
|
+
clearEnd: number,
|
|
58
|
+
ease: EaseFn = smoothstep,
|
|
59
|
+
): EaseFn {
|
|
60
|
+
return (p) => {
|
|
61
|
+
if (p < clearStart) {
|
|
62
|
+
if (clearStart <= 0) return 0;
|
|
63
|
+
return 1 - ease(p / clearStart);
|
|
64
|
+
}
|
|
65
|
+
if (p > clearEnd) {
|
|
66
|
+
if (clearEnd >= 1) return 0;
|
|
67
|
+
return ease((p - clearEnd) / (1 - clearEnd));
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Three-zone plateau envelope — the inverse of {@link scrollZones}.
|
|
75
|
+
*
|
|
76
|
+
* scrollPlateau(0.3, 0.7) — rises from 0 at `p = 0` up to 1 at
|
|
77
|
+
* `p = 0.3` (smoothly by default), holds at 1 across the clear zone
|
|
78
|
+
* (`0.3 ≤ p ≤ 0.7`), then falls back to 0 by `p = 1`.
|
|
79
|
+
*
|
|
80
|
+
* Designed for `createScrollTransition` where "identity" is the fully
|
|
81
|
+
* transitioned state (progress 1). The transition plays in as the
|
|
82
|
+
* section enters, holds its final frame across the clear zone, then
|
|
83
|
+
* plays back out (reverse) as the section exits. Smoothstep default
|
|
84
|
+
* prevents the harsh snap that a linear ramp produces at the plateau
|
|
85
|
+
* boundaries.
|
|
86
|
+
*/
|
|
87
|
+
export function scrollPlateau(
|
|
88
|
+
clearStart: number,
|
|
89
|
+
clearEnd: number,
|
|
90
|
+
ease: EaseFn = smoothstep,
|
|
91
|
+
): EaseFn {
|
|
92
|
+
return (p) => {
|
|
93
|
+
if (p < clearStart) {
|
|
94
|
+
if (clearStart <= 0) return 1;
|
|
95
|
+
return ease(p / clearStart);
|
|
96
|
+
}
|
|
97
|
+
if (p > clearEnd) {
|
|
98
|
+
if (clearEnd >= 1) return 1;
|
|
99
|
+
return 1 - ease((p - clearEnd) / (1 - clearEnd));
|
|
100
|
+
}
|
|
101
|
+
return 1;
|
|
102
|
+
};
|
|
103
|
+
}
|