@linear_non/stellar-libs 1.3.0 → 1.4.2

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/README.md CHANGED
@@ -1,72 +1,180 @@
1
1
  # stellar-libs
2
2
 
3
- A collection of lightweight, reusable frontend UI behavior modules — built to work seamlessly with [`stellar-kit`](https://www.npmjs.com/package/@linear_non/stellar-kit) and the [Stellar](https://github.com/nonlinearstudio/stellar) frontend system. Each library is self-contained, configurable, and designed to be dropped into modular websites. Also is a project that is constantly growing and we are adding new updates all the time.
3
+ > `@linear_non/stellar-libs` Reusable UI behavior modules for Non-Linear Studio projects.
4
4
 
5
- ## Available Libs
5
+ A collection of lightweight, self-contained frontend UI behavior modules built to work seamlessly with [`@linear_non/stellar-kit`](https://www.npmjs.com/package/@linear_non/stellar-kit). Each library provides specific functionality and can be dropped into modular websites with minimal configuration.
6
6
 
7
- - Designed for plug-and-play
8
- - **Sticky**: Basic sticky logic for DOM elements
9
- - **Smooth**: Smooth scroll instance using Virtual Scroll
10
- - **SplitOnScroll**: Text splitting + reveal synced to scroll
11
- - **Noise**: Configurable canvas noise
12
- - **SpritePlayer**: Canvas image sequence player
7
+ ## Available Libraries
13
8
 
14
- ## 📦 Installation
9
+ | Library | Description |
10
+ |---|---|
11
+ | **Sticky** | Sticky scroll behavior with GSAP ScrollTrigger |
12
+ | **Smooth** | Smooth scroll system using VirtualScroll |
13
+ | **SplitOnScroll** | Text splitting with scroll-triggered reveals |
14
+ | **Noise** | Canvas-based animated noise effect |
15
+ | **SpritePlayer** | Image sequence player on canvas |
16
+ | **CursorTracker** | Custom cursor that follows mouse |
17
+ | **Marquee** | Infinite horizontal marquee scroller |
18
+ | **Slider** | Drag slider with optional infinite loop and progress |
19
+
20
+ ## Installation
15
21
 
16
22
  ```bash
17
23
  npm install @linear_non/stellar-libs
24
+ npm install @linear_non/stellar-kit
18
25
  ```
19
26
 
20
- > Note: Most libraries rely on `stellar-kit` for shared events, scroll, or utilities. Be sure to install both:
27
+ ## Local Development
21
28
 
22
29
  ```bash
23
- npm install @linear_non/stellar-kit
30
+ npm install
31
+ npm run dev
32
+ ```
33
+
34
+ Launches a Vite dev server with a demo index linking to individual playgrounds for each library.
35
+
36
+ ---
37
+
38
+ ## Usage
39
+
40
+ ### Sticky
41
+
42
+ ```js
43
+ import { Sticky } from "@linear_non/stellar-libs"
44
+
45
+ const sticky = new Sticky({
46
+ el: document.querySelector(".sticky-container"),
47
+ sticky: document.querySelector(".sticky-element"),
48
+ isSmooth: false,
49
+ onUpdateCallback: ({ progress }) => console.log(progress),
50
+ })
24
51
  ```
25
52
 
26
- ## 📁 Folder Structure
53
+ ### Smooth
27
54
 
55
+ ```js
56
+ import { Smooth } from "@linear_non/stellar-libs"
57
+
58
+ const smooth = new Smooth({ scroll: kitStore.scroll })
28
59
  ```
29
- stellar-libs/
30
- ├── src/
31
- │ ├── Sticky/ # Sticky behavior module
32
- │ ├── SplitOnScroll/ # Scroll-triggered text reveals
33
- │ ├── Smooth/ # Smooth scrolling system
34
- │ └── ScrollBar/ # Custom scrollbar component
35
- │ └── Noise/ # Canvas noise
36
- │ └── SpritePlayer/ # Image sprite player
37
- ├── dev/ # Dev playgrounds for each lib
38
- ├── index.js # Entry point for exports
39
- └── vite.config.js # Vite config to run individual demos
60
+
61
+ ```html
62
+ <section data-smooth data-speed="0.8">Parallax content</section>
40
63
  ```
41
64
 
42
- ## 🧪 Local Development
65
+ ### SplitOnScroll
43
66
 
44
- ```bash
45
- npm install
46
- npm run dev
67
+ ```js
68
+ import { SplitOnScroll } from "@linear_non/stellar-libs"
69
+
70
+ const split = new SplitOnScroll({
71
+ el: document.querySelector(".trigger"),
72
+ splitText: document.querySelectorAll("h1"),
73
+ isReady: ({ splits, groups }) => {
74
+ gsap.from(splits[0].chars, { y: 40, opacity: 0, stagger: 0.03 })
75
+ },
76
+ once: true,
77
+ })
47
78
  ```
48
79
 
49
- > This launches a Vite server and scans `dev/*/index.html` to preview each module. You can open individual demos for testing.
80
+ > The constructor key is `splitText` (not `splitTargets`). The `isReady` callback receives `{ splits, groups }`.
50
81
 
51
- ## 🛠️ Usage Example
82
+ ### Noise
52
83
 
53
84
  ```js
54
- import { Sticky } from "@linear_non/stellar-libs"
85
+ import { Noise } from "@linear_non/stellar-libs"
86
+
87
+ const noise = new Noise({
88
+ target: ".noise-canvas",
89
+ density: 0.75,
90
+ color: 0xf3f2feff,
91
+ opacity: 0.25,
92
+ fps: 24,
93
+ })
94
+ ```
55
95
 
56
- const sticky = new Sticky({
57
- el: qs(".sticky"),
58
- sticky: qs(".sticky-content"),
96
+ ### SpritePlayer
97
+
98
+ ```js
99
+ import { SpritePlayer } from "@linear_non/stellar-libs"
100
+
101
+ const player = new SpritePlayer({
102
+ canvas: document.querySelector("canvas"),
103
+ container: document.querySelector(".player-container"),
104
+ desktop_path: "/sprites/desktop/",
105
+ mobile_path: "/sprites/mobile/",
106
+ fileName: "frame_{index}",
107
+ total: 60,
108
+ })
109
+
110
+ await player.init()
111
+ player.setProgress(0.5)
112
+ ```
113
+
114
+ ### CursorTracker
115
+
116
+ ```js
117
+ import { CursorTracker } from "@linear_non/stellar-libs"
118
+
119
+ const tracker = new CursorTracker({
120
+ container: document.querySelector(".hover-area"),
121
+ el: document.querySelector(".cursor"),
122
+ ease: 0.12,
123
+ isCentered: true,
124
+ })
125
+ ```
126
+
127
+ > `CursorTracker` subscribes to `APP_TICK` internally — do not add an external tick subscription.
128
+
129
+ ### Marquee
130
+
131
+ ```js
132
+ import { Marquee } from "@linear_non/stellar-libs"
133
+
134
+ const marquee = new Marquee({
135
+ container: document.querySelector(".marquee"),
136
+ el: document.querySelector(".marquee__track"),
137
+ speed: 50,
138
+ gap: "1.6rem",
59
139
  })
60
140
  ```
61
141
 
62
- > You can find usage examples in each library’s README file also an HTML markup example. You can see a live example inside the `dev/` folder.
142
+ ### Slider
143
+
144
+ ```js
145
+ import { Slider } from "@linear_non/stellar-libs"
146
+
147
+ const slider = new Slider({
148
+ container: document.querySelector(".slider"),
149
+ el: document.querySelector(".slider__track"),
150
+ speed: 0.1,
151
+ snap: true,
152
+ infinite: false,
153
+ prevEl: document.querySelector(".prev"),
154
+ nextEl: document.querySelector(".next"),
155
+ progressEl: document.querySelector(".progress-fill"),
156
+ onSlideChange: ({ index, total }) => console.log(index, total),
157
+ })
158
+ ```
159
+
160
+ ```html
161
+ <div class="slider">
162
+ <div class="slider__track">
163
+ <div class="slide">01</div>
164
+ <div class="slide">02</div>
165
+ <div class="slide">03</div>
166
+ </div>
167
+ </div>
168
+ ```
63
169
 
64
170
  ---
65
171
 
66
- ### ✅ TODO
172
+ ## Lifecycle
173
+
174
+ All libraries follow a consistent class-based lifecycle: `init() → on() → tick() → resize() → off() → destroy()`
67
175
 
68
- This is a projects that will continue growing and we have plan to add as many things that we feel can be helpful for us in the future.
176
+ Always call `destroy()` on page navigation to clean up event listeners, animations, and timers.
69
177
 
70
178
  ---
71
179
 
72
- Made with ❤️ by [Non-Linear Studio](https://non-linear.studio)
180
+ Made with love by [Non-Linear Studio](https://non-linear.studio)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linear_non/stellar-libs",
3
- "version": "1.3.0",
3
+ "version": "1.4.2",
4
4
  "description": "Reusable JavaScript libraries for Non-Linear Studio projects.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,7 +11,7 @@ export default class Marquee {
11
11
  this.init()
12
12
  }
13
13
 
14
- setup() {
14
+ setup = () => {
15
15
  if (this.tween) this.tween.kill()
16
16
 
17
17
  gsap.set(this.container, {
@@ -0,0 +1,375 @@
1
+ import { lerp, bounds, sniffer } from "@linear_non/stellar-kit/utils"
2
+ import { gsap } from "@linear_non/stellar-kit/gsap"
3
+ import { emitter, EVENTS } from "@linear_non/stellar-kit/events"
4
+
5
+ export default class Slider {
6
+ constructor({
7
+ container,
8
+ el,
9
+ speed = 0.1,
10
+ snap = true,
11
+ infinite = false,
12
+ mobileOnly = false,
13
+ prevEl = null,
14
+ nextEl = null,
15
+ progressEl = null,
16
+ onSlideChange = null,
17
+ }) {
18
+ this.container = container
19
+ this.el = el
20
+ this.speed = speed
21
+ this.snap = infinite ? true : snap // infinite always snaps
22
+ this.infinite = infinite
23
+ this.mobileOnly = mobileOnly
24
+ this.prevEl = prevEl
25
+ this.nextEl = nextEl
26
+ this.progressEl = progressEl
27
+ this.onSlideChange = typeof onSlideChange === "function" ? onSlideChange : null
28
+
29
+ this.isActive = false
30
+ this.isDown = false
31
+ this.isDragging = false
32
+
33
+ this.x = { start: 0, state: 0 }
34
+ this.current = 0
35
+ this.target = 0
36
+ this.index = 0 // 0-based real index (infinite: derived from position)
37
+ this.total = 0
38
+ this.slideWidth = 0
39
+ this.setW = 0 // total width of one full set of real slides (infinite mode)
40
+ this.limit = 0
41
+
42
+ this.dpr = Math.max(1, Math.round(window.devicePixelRatio || 1))
43
+ this._snap2px = v => Math.round(v * this.dpr) / this.dpr
44
+
45
+ this.init()
46
+ }
47
+
48
+ // ─── Setup ────────────────────────────────────────────────────────────────
49
+
50
+ _shouldActivate() {
51
+ return !this.mobileOnly || sniffer.isDevice
52
+ }
53
+
54
+ setup() {
55
+ this.isActive = this._shouldActivate()
56
+ if (!this.isActive) return
57
+
58
+ if (this.infinite) {
59
+ this._setupInfinite()
60
+ } else {
61
+ this._measure()
62
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index)
63
+ this.target = this.index * this.slideWidth
64
+ this.current = this.target
65
+ this._draw(this.current)
66
+ }
67
+ }
68
+
69
+ _measure() {
70
+ // Only measure non-clone slides for width/total
71
+ const rawSlides = Array.from(this.el.children).filter(s => !s.dataset.clone)
72
+ this.total = rawSlides.length
73
+ if (!this.total) return
74
+
75
+ const gapPx = parseFloat(getComputedStyle(this.el).gap) || 0
76
+ this.slideWidth = rawSlides[0].getBoundingClientRect().width + gapPx
77
+ this.setW = this.slideWidth * this.total
78
+ const viewWidth = bounds(this.container).width
79
+ this.limit = Math.max(0, this.setW - viewWidth)
80
+ }
81
+
82
+ _setupInfinite() {
83
+ // Remove any stale clones from previous setup
84
+ Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
85
+
86
+ const rawSlides = Array.from(this.el.children)
87
+ if (rawSlides.length < 2) return
88
+
89
+ // Clone the ENTIRE set before and after (tripling) so the user can never
90
+ // reach a real edge regardless of drag speed or prev/next calls.
91
+ const cloneBefore = rawSlides.map(s => {
92
+ const c = s.cloneNode(true)
93
+ c.setAttribute("aria-hidden", "true")
94
+ c.dataset.clone = "before"
95
+ return c
96
+ })
97
+ const cloneAfter = rawSlides.map(s => {
98
+ const c = s.cloneNode(true)
99
+ c.setAttribute("aria-hidden", "true")
100
+ c.dataset.clone = "after"
101
+ return c
102
+ })
103
+
104
+ // Prepend "before" in correct visual order, append "after"
105
+ ;[...cloneBefore].reverse().forEach(c => this.el.prepend(c))
106
+ cloneAfter.forEach(c => this.el.appendChild(c))
107
+
108
+ this._measure()
109
+
110
+ // x convention: translateX(-x). Start at setW so real slide 0 is visible.
111
+ // Track layout: [before clones 0..setW) [real slides setW..2setW) [after clones 2setW..3setW)
112
+ this.index = 0
113
+ this.current = this.setW
114
+ this.target = this.setW
115
+ this._draw(this.current)
116
+ }
117
+
118
+ // ─── Infinite wrap (runs every tick) ──────────────────────────────────────
119
+ // Silently teleport when position drifts outside the real-set zone.
120
+ // Because we cloned full sets on both sides this jump is always invisible.
121
+
122
+ _wrapInfinite() {
123
+ if (!this.setW) return
124
+ let shift = 0
125
+ if (this.current < 0) {
126
+ shift = Math.ceil(-this.current / this.setW) * this.setW
127
+ } else if (this.current >= 2 * this.setW) {
128
+ shift = -Math.floor((this.current - this.setW) / this.setW) * this.setW
129
+ }
130
+ if (shift !== 0) {
131
+ this.current += shift
132
+ this.target += shift
133
+ // Keep the drag reference in sync so the next onPointerMove doesn't
134
+ // compute a target relative to the pre-wrap baseline, which would cause
135
+ // the slider to snap back to the wrong position.
136
+ if (this.isDown) this.x.state += shift
137
+ }
138
+ }
139
+
140
+ // ─── Render ───────────────────────────────────────────────────────────────
141
+
142
+ _draw(x) {
143
+ this.el.style.transform = `translate3d(${-this._snap2px(x)}px, 0, 0)`
144
+ }
145
+
146
+ _updateProgress() {
147
+ if (!this.progressEl || this.infinite) return
148
+ const progress = this.limit > 0 ? this.current / this.limit : 0
149
+ this.progressEl.style.transformOrigin = "left center"
150
+ this.progressEl.style.transform = `scaleX(${gsap.utils.clamp(0, 1, progress)})`
151
+ }
152
+
153
+ // ─── Tick ─────────────────────────────────────────────────────────────────
154
+
155
+ tick = () => {
156
+ if (!this.isActive) return
157
+
158
+ const prev = this.current
159
+ this.current = lerp(this.current, this.target, this.speed)
160
+
161
+ // Snap to exact value when close enough to avoid sub-pixel drift
162
+ if (Math.abs(this.current - this.target) < 0.01) {
163
+ this.current = this.target
164
+ }
165
+
166
+ // Infinite: wrap on every tick (not just when settled) so a fast drag
167
+ // that overshoots the clone zone is corrected immediately.
168
+ if (this.infinite) this._wrapInfinite()
169
+
170
+ if (this.current !== prev) {
171
+ this._draw(this.current)
172
+ this._updateProgress()
173
+ }
174
+ }
175
+
176
+ // ─── Snap ─────────────────────────────────────────────────────────────────
177
+
178
+ _snapToNearest() {
179
+ if (!this.slideWidth) return
180
+
181
+ if (this.infinite) {
182
+ // Round to nearest slide in ABSOLUTE (un-normalised) space.
183
+ // Do NOT clamp target back into [0, 2·setW) here — that would invert the
184
+ // lerp direction if the user released before current crossed the wrap
185
+ // boundary, causing a visible "all-slides-jump-backward" reset.
186
+ // Instead, keep target in whatever cycle it is; _wrapInfinite will shift
187
+ // both current and target together tick-by-tick as current naturally
188
+ // crosses each setW boundary — always seamless, always forward.
189
+ this.target = Math.round(this.target / this.slideWidth) * this.slideWidth
190
+ // Derive real 0-based index via modulo on normalised position
191
+ const normPos = ((this.target % this.setW) + this.setW) % this.setW
192
+ this.index = Math.round(normPos / this.slideWidth) % this.total
193
+ } else {
194
+ this.index = gsap.utils.clamp(0, this.total - 1, Math.round(this.target / this.slideWidth))
195
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
196
+ }
197
+
198
+ this._fireChange()
199
+ }
200
+
201
+ _fireChange() {
202
+ if (!this.onSlideChange) return
203
+ this.onSlideChange({ index: this.index, total: this.total })
204
+ }
205
+
206
+ // ─── Public navigation ────────────────────────────────────────────────────
207
+
208
+ next = () => {
209
+ if (!this.isActive) return
210
+ if (this.infinite) {
211
+ this.target += this.slideWidth
212
+ this.index = (this.index + 1) % this.total
213
+ } else {
214
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index + 1)
215
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
216
+ }
217
+ this._fireChange()
218
+ }
219
+
220
+ prev = () => {
221
+ if (!this.isActive) return
222
+ if (this.infinite) {
223
+ this.target -= this.slideWidth
224
+ this.index = (this.index - 1 + this.total) % this.total
225
+ } else {
226
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index - 1)
227
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
228
+ }
229
+ this._fireChange()
230
+ }
231
+
232
+ goTo(i) {
233
+ if (!this.isActive) return
234
+ const idx = gsap.utils.clamp(0, this.total - 1, i)
235
+ if (this.infinite) {
236
+ // Find the shortest delta from current position to target idx
237
+ const currentIdx = this.index
238
+ let delta = idx - currentIdx
239
+ if (Math.abs(delta) > this.total / 2) {
240
+ delta = delta > 0 ? delta - this.total : delta + this.total
241
+ }
242
+ this.target += delta * this.slideWidth
243
+ } else {
244
+ this.target = gsap.utils.clamp(0, this.limit, idx * this.slideWidth)
245
+ }
246
+ this.index = idx
247
+ this._fireChange()
248
+ }
249
+
250
+ // ─── Pointer events ───────────────────────────────────────────────────────
251
+
252
+ onPointerDown = e => {
253
+ if (!this.isActive) return
254
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
255
+ this.isDown = true
256
+ this.x.start = clientX
257
+ this.x.state = this.target
258
+ this.container.classList.add("-dragging")
259
+ }
260
+
261
+ onPointerMove = e => {
262
+ if (!this.isActive || !this.isDown) return
263
+ this.isDragging = true
264
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
265
+ const delta = this.x.start - clientX
266
+ let newTarget = this.x.state + delta
267
+
268
+ if (!this.infinite) {
269
+ newTarget = gsap.utils.clamp(0, this.limit, newTarget)
270
+ }
271
+
272
+ this.target = newTarget
273
+ }
274
+
275
+ onPointerUp = () => {
276
+ if (!this.isActive) return
277
+ this.isDown = false
278
+ this.container.classList.remove("-dragging")
279
+
280
+ if (this.isDragging && this.snap) {
281
+ this._snapToNearest()
282
+ }
283
+
284
+ this.isDragging = false
285
+ }
286
+
287
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
288
+
289
+ on() {
290
+ if (!this.isActive) return
291
+
292
+ this.container.addEventListener("mousedown", this.onPointerDown)
293
+ this.container.addEventListener("touchstart", this.onPointerDown, { passive: true })
294
+ window.addEventListener("mousemove", this.onPointerMove)
295
+ window.addEventListener("touchmove", this.onPointerMove, { passive: true })
296
+ window.addEventListener("mouseup", this.onPointerUp)
297
+ window.addEventListener("touchend", this.onPointerUp)
298
+
299
+ if (this.prevEl) this.prevEl.addEventListener("click", this.prev)
300
+ if (this.nextEl) this.nextEl.addEventListener("click", this.next)
301
+
302
+ emitter.on(EVENTS.APP_TICK, this.tick)
303
+ emitter.on(EVENTS.APP_RESIZE, this.resize)
304
+ }
305
+
306
+ off() {
307
+ this.container?.removeEventListener("mousedown", this.onPointerDown)
308
+ this.container?.removeEventListener("touchstart", this.onPointerDown)
309
+ window.removeEventListener("mousemove", this.onPointerMove)
310
+ window.removeEventListener("touchmove", this.onPointerMove)
311
+ window.removeEventListener("mouseup", this.onPointerUp)
312
+ window.removeEventListener("touchend", this.onPointerUp)
313
+
314
+ if (this.prevEl) this.prevEl.removeEventListener("click", this.prev)
315
+ if (this.nextEl) this.nextEl.removeEventListener("click", this.next)
316
+
317
+ emitter.off(EVENTS.APP_TICK, this.tick)
318
+ emitter.off(EVENTS.APP_RESIZE, this.resize)
319
+ }
320
+
321
+ resize = () => {
322
+ const wasActive = this.isActive
323
+ this.isActive = this._shouldActivate()
324
+
325
+ // Was active, now inactive (e.g. mobileOnly + resized to desktop)
326
+ if (wasActive && !this.isActive) {
327
+ this._reset()
328
+ this.off()
329
+ return
330
+ }
331
+
332
+ // Was inactive, now active (e.g. mobileOnly + resized to mobile)
333
+ if (!wasActive && this.isActive) {
334
+ this.setup()
335
+ this.on()
336
+ return
337
+ }
338
+
339
+ if (!this.isActive) return
340
+
341
+ if (this.infinite) {
342
+ // Rebuild clones and remeasure, keeping current real index visible
343
+ const savedIndex = this.index
344
+ this._setupInfinite()
345
+ this.index = savedIndex
346
+ this.current = this.target = this.setW + savedIndex * this.slideWidth
347
+ this._draw(this.current)
348
+ } else {
349
+ this._measure()
350
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
351
+ this.current = this.target
352
+ this._draw(this.current)
353
+ this._updateProgress()
354
+ }
355
+ }
356
+
357
+ _reset() {
358
+ Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
359
+ this.el.style.transform = ""
360
+ this.current = this.target = 0
361
+ this.index = 0
362
+ }
363
+
364
+ destroy() {
365
+ this.off()
366
+ this._reset()
367
+ this.container = null
368
+ this.el = null
369
+ }
370
+
371
+ init() {
372
+ this.setup()
373
+ this.on()
374
+ }
375
+ }
@@ -1,4 +1,5 @@
1
1
  import Scrollbar from "../ScrollBar"
2
+ import { gsap } from "@linear_non/stellar-kit/gsap"
2
3
  import { kitStore } from "@linear_non/stellar-kit"
3
4
  import { emitter, EVENTS } from "@linear_non/stellar-kit/events"
4
5
  import { qs, qsa, bounds, Debug } from "@linear_non/stellar-kit/utils"
@@ -24,7 +25,7 @@ export default class Smooth {
24
25
  let cursor = 0
25
26
 
26
27
  this.elems.forEach(el => {
27
- el.style.transform = "translate3d(0, 0, 0)"
28
+ gsap.set(el, { y: 0 })
28
29
  const raw = el.dataset.speed != null ? parseFloat(el.dataset.speed) : 1
29
30
  const speed = Number.isFinite(raw) ? raw : 1
30
31
  const { height, offset } = this.getVars(el, speed)
@@ -39,6 +40,7 @@ export default class Smooth {
39
40
 
40
41
  this.sections.push({
41
42
  el,
43
+ ySet: gsap.quickSetter(el, "y", "px"),
42
44
  parent,
43
45
  top,
44
46
  bottom,
@@ -66,9 +68,9 @@ export default class Smooth {
66
68
  this.sections.forEach(section => {
67
69
  const { isVisible, transform } = this.isVisible(section)
68
70
  if (isVisible || isResizing || !section.out) {
69
- const { y, css } = this.getTransform(transform)
71
+ const y = this.getY(transform)
70
72
  if (y !== section._roundedY) {
71
- section.el.style.transform = css
73
+ section.ySet(y)
72
74
  section._roundedY = y
73
75
  }
74
76
  section.out = !isVisible
@@ -90,10 +92,9 @@ export default class Smooth {
90
92
  return { isVisible, transform }
91
93
  }
92
94
 
93
- getTransform(transform) {
94
- let y = -transform
95
- y = Math.round(y * this.dpr) / this.dpr
96
- return { y, css: `translate3d(0, ${y}px, 0)` }
95
+ getY(transform) {
96
+ const y = -transform
97
+ return Math.round(y * this.dpr) / this.dpr
97
98
  }
98
99
 
99
100
  getVars(el, speed) {
@@ -137,7 +138,7 @@ export default class Smooth {
137
138
  }
138
139
 
139
140
  clean() {
140
- if (this.sections) this.sections.forEach(s => (s.el.style.transform = ""))
141
+ if (this.sections) this.sections.forEach(s => gsap.set(s.el, { y: 0, clearProps: "transform" }))
141
142
  this.elems = this.sections = null
142
143
  }
143
144
 
@@ -124,7 +124,7 @@ export default class SpritePlayer {
124
124
  this.dom.alphaImages = alphaImages
125
125
 
126
126
  if (this.isAlpha && this.dom.alphaImages.length) {
127
- this.dom.alphaImages = this.dom.alphaImages.map(img => this.toAlphaFromLuma(img))
127
+ this.dom.alphaImages = await Promise.all(this.dom.alphaImages.map(img => this.toAlphaFromLuma(img)))
128
128
  }
129
129
  if (this.isAlpha && this.dom.alphaImages.length !== this.dom.images.length) {
130
130
  this.isAlpha = false
@@ -154,8 +154,8 @@ export default class SpritePlayer {
154
154
  Object.assign(this.canvas, { width: rect.width, height: rect.height, rect, area, offset })
155
155
  }
156
156
 
157
- // grayscale → alpha (returns a canvas)
158
- toAlphaFromLuma(img) {
157
+ // grayscale → alpha (returns an ImageBitmap; temp canvas backing store freed immediately)
158
+ async toAlphaFromLuma(img) {
159
159
  const w = img.naturalWidth || img.width
160
160
  const h = img.naturalHeight || img.height
161
161
  const c = document.createElement("canvas")
@@ -173,7 +173,11 @@ export default class SpritePlayer {
173
173
  a[i + 3] = lum
174
174
  }
175
175
  x.putImageData(d, 0, 0)
176
- return c
176
+ const bitmap = await createImageBitmap(c)
177
+ // Release backing store — bitmap is now the only reference needed
178
+ c.width = 0
179
+ c.height = 0
180
+ return bitmap
177
181
  }
178
182
 
179
183
  drawImageCover(ctx, img, area, offset) {
@@ -254,6 +258,7 @@ export default class SpritePlayer {
254
258
  if (!this.persistent) {
255
259
  this._loaders.forEach(l => l.destroy?.())
256
260
  }
261
+ this.dom.alphaImages.forEach(bmp => bmp?.close?.())
257
262
  this.dom.images = []
258
263
  this.dom.alphaImages = []
259
264
  this._loaders = []
package/src/index.js CHANGED
@@ -5,5 +5,6 @@ import Noise from "./Noise"
5
5
  import SpritePlayer from "./SpritePlayer"
6
6
  import CursorTracker from "./CursorTracker"
7
7
  import Marquee from "./Marquee"
8
+ import Slider from "./Slider"
8
9
 
9
- export { Sticky, Smooth, SplitOnScroll, Noise, SpritePlayer, CursorTracker, Marquee }
10
+ export { Sticky, Smooth, SplitOnScroll, Noise, SpritePlayer, CursorTracker, Marquee, Slider }