@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +6 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/observer.d.ts +36 -0
  8. package/dist/observer.d.ts.map +1 -0
  9. package/dist/observer.js +82 -0
  10. package/dist/observer.js.map +1 -0
  11. package/dist/progress.d.ts +14 -0
  12. package/dist/progress.d.ts.map +1 -0
  13. package/dist/progress.js +36 -0
  14. package/dist/progress.js.map +1 -0
  15. package/dist/scroll-effect.d.ts +37 -0
  16. package/dist/scroll-effect.d.ts.map +1 -0
  17. package/dist/scroll-effect.js +39 -0
  18. package/dist/scroll-effect.js.map +1 -0
  19. package/dist/scroll-transition.d.ts +38 -0
  20. package/dist/scroll-transition.d.ts.map +1 -0
  21. package/dist/scroll-transition.js +42 -0
  22. package/dist/scroll-transition.js.map +1 -0
  23. package/dist/types.d.ts +18 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +2 -0
  26. package/dist/types.js.map +1 -0
  27. package/dist/zones.d.ts +56 -0
  28. package/dist/zones.d.ts.map +1 -0
  29. package/dist/zones.js +94 -0
  30. package/dist/zones.js.map +1 -0
  31. package/package.json +52 -0
  32. package/src/__tests__/__screenshots__/progress.test.ts/createScrollProgress-reaches-1-after-fully-scrolling-past-the-element-1.png +0 -0
  33. package/src/__tests__/observer.test.ts +60 -0
  34. package/src/__tests__/progress.test.ts +126 -0
  35. package/src/__tests__/scroll-effect.test.ts +135 -0
  36. package/src/__tests__/scroll-transition.test.ts +167 -0
  37. package/src/__tests__/ssr.test.ts +37 -0
  38. package/src/__tests__/types-check.ts +86 -0
  39. package/src/__tests__/zones.test.ts +175 -0
  40. package/src/index.ts +9 -0
  41. package/src/observer.ts +89 -0
  42. package/src/progress.ts +39 -0
  43. package/src/scroll-effect.ts +72 -0
  44. package/src/scroll-transition.ts +80 -0
  45. package/src/types.ts +19 -0
  46. 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";
@@ -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
+ }
@@ -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
+ }