@linear_non/stellar-libs 1.3.1 → 1.4.3

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,71 +1,190 @@
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
24
32
  ```
25
33
 
26
- ## 📁 Folder Structure
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
27
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
+ })
28
51
  ```
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
52
+
53
+ ### Smooth
54
+
55
+ ```js
56
+ import { Smooth } from "@linear_non/stellar-libs"
57
+
58
+ const smooth = new Smooth({ scroll: kitStore.scroll })
40
59
  ```
41
60
 
42
- ## 🧪 Local Development
61
+ ```html
62
+ <section data-smooth data-speed="0.8">Parallax content</section>
63
+ ```
43
64
 
44
- ```bash
45
- npm install
46
- npm run dev
65
+ ### SplitOnScroll
66
+
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,
59
124
  })
60
125
  ```
61
126
 
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.
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",
139
+ })
140
+ ```
141
+
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
+ activeBelow: 'mediumUp', // only active below the mediumUp breakpoint; mobileOnly:true is an alias
154
+ prevEl: document.querySelector(".prev"),
155
+ nextEl: document.querySelector(".next"),
156
+ progressEl: document.querySelector(".progress-fill"),
157
+ onSlideChange: ({ index, total }) => console.log(index, total),
158
+ })
159
+ ```
160
+
161
+ ```html
162
+ <div class="slider">
163
+ <div class="slider__track">
164
+ <div class="slide">01</div>
165
+ <div class="slide">02</div>
166
+ <div class="slide">03</div>
167
+ </div>
168
+ </div>
169
+ ```
63
170
 
64
171
  ---
65
172
 
66
- ### ✅ TODO
173
+ ## Roadmap
174
+
175
+ - [ ] TypeScript definitions (`.d.ts` for all 8 libraries)
176
+ - [ ] Unit test suite (per-library Jest tests)
177
+ - [ ] Accessibility — ARIA attributes + keyboard support (Slider, Marquee, CursorTracker)
178
+ - [ ] Animation presets — exportable GSAP timeline builders per library
179
+ - [ ] Performance monitoring — FPS tracking integrated with stellar-kit `?debug`
180
+ - [ ] Storybook interactive docs
181
+ - [ ] Plugin system — `.use(plugin)` extension API
182
+
183
+ ## Lifecycle
184
+
185
+ All libraries follow a consistent class-based lifecycle: `init() → on() → tick() → resize() → off() → destroy()`
67
186
 
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.
187
+ Always call `destroy()` on page navigation to clean up event listeners, animations, and timers.
69
188
 
70
189
  ---
71
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linear_non/stellar-libs",
3
- "version": "1.3.1",
3
+ "version": "1.4.3",
4
4
  "description": "Reusable JavaScript libraries for Non-Linear Studio projects.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,10 +10,6 @@
10
10
  "files": [
11
11
  "src"
12
12
  ],
13
- "scripts": {
14
- "dev": "vite",
15
- "build": "vite build"
16
- },
17
13
  "keywords": [
18
14
  "non-linear",
19
15
  "frontend",
@@ -25,10 +21,14 @@
25
21
  "author": "Non-Linear Studio",
26
22
  "license": "MIT",
27
23
  "dependencies": {
28
- "@linear_non/stellar-kit": "*"
24
+ "@linear_non/stellar-kit": "3.0.12"
29
25
  },
30
26
  "devDependencies": {
31
27
  "@linear_non/prettier-config": "^1.0.6",
32
28
  "vite": "^6.3.5"
29
+ },
30
+ "scripts": {
31
+ "dev": "vite",
32
+ "build": "vite build"
33
33
  }
34
- }
34
+ }
@@ -0,0 +1,391 @@
1
+ import { lerp, bounds } 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
+ import { kitStore } from "@linear_non/stellar-kit"
5
+
6
+ export default class Slider {
7
+ constructor({
8
+ container,
9
+ el,
10
+ speed = 0.1,
11
+ snap = true,
12
+ infinite = false,
13
+ // activeBelow: a kitStore.breakpoints key (e.g. 'mediumUp', 'smallUp').
14
+ // Slider is active when kitStore.breakpoints[activeBelow] is falsy (i.e. we are below that breakpoint).
15
+ // mobileOnly: true is kept as a backward-compat alias for activeBelow: 'mediumUp'.
16
+ activeBelow = null,
17
+ mobileOnly = false,
18
+ prevEl = null,
19
+ nextEl = null,
20
+ progressEl = null,
21
+ onSlideChange = null,
22
+ }) {
23
+ this.container = container
24
+ this.el = el
25
+ this.speed = speed
26
+ this.snap = infinite ? true : snap // infinite always snaps
27
+ this.infinite = infinite
28
+ // Resolve activeBelow: explicit prop wins; mobileOnly:true falls back to 'mediumUp'
29
+ this.activeBelow = activeBelow || (mobileOnly ? 'mediumUp' : null)
30
+ this.prevEl = prevEl
31
+ this.nextEl = nextEl
32
+ this.progressEl = progressEl
33
+ this.onSlideChange = typeof onSlideChange === "function" ? onSlideChange : null
34
+
35
+ this.isActive = false
36
+ this.isDown = false
37
+ this.isDragging = false
38
+
39
+ this.x = { start: 0, state: 0 }
40
+ this.current = 0
41
+ this.target = 0
42
+ this.index = 0 // 0-based real index (infinite: derived from position)
43
+ this.total = 0
44
+ this.slideWidth = 0
45
+ this.setW = 0 // total width of one full set of real slides (infinite mode)
46
+ this.limit = 0
47
+
48
+ this.dpr = Math.max(1, Math.round(window.devicePixelRatio || 1))
49
+ this._snap2px = v => Math.round(v * this.dpr) / this.dpr
50
+
51
+ this.init()
52
+ }
53
+
54
+ // ─── Setup ────────────────────────────────────────────────────────────────
55
+
56
+ _shouldActivate() {
57
+ if (!this.activeBelow) return true
58
+ // Active when the named breakpoint flag is falsy (i.e. we are below that breakpoint)
59
+ return !kitStore.breakpoints[this.activeBelow]
60
+ }
61
+
62
+ setup() {
63
+ this.isActive = this._shouldActivate()
64
+ if (!this.isActive) return
65
+
66
+ if (this.infinite) {
67
+ this._setupInfinite()
68
+ } else {
69
+ this._measure()
70
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index)
71
+ this.target = this.index * this.slideWidth
72
+ this.current = this.target
73
+ this._draw(this.current)
74
+ this._updateProgress()
75
+ }
76
+ }
77
+
78
+ _measure() {
79
+ // Only measure non-clone slides for width/total
80
+ const rawSlides = Array.from(this.el.children).filter(s => !s.dataset.clone)
81
+ this.total = rawSlides.length
82
+ if (!this.total) return
83
+
84
+ const gapPx = parseFloat(getComputedStyle(this.el).gap) || 0
85
+ this.slideWidth = rawSlides[0].getBoundingClientRect().width + gapPx
86
+ this.setW = this.slideWidth * this.total
87
+ const viewWidth = bounds(this.container).width
88
+ this.limit = Math.max(0, this.setW - viewWidth)
89
+ }
90
+
91
+ _setupInfinite() {
92
+ // Remove any stale clones from previous setup
93
+ Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
94
+
95
+ const rawSlides = Array.from(this.el.children)
96
+ if (rawSlides.length < 2) return
97
+
98
+ // Clone the ENTIRE set before and after (tripling) so the user can never
99
+ // reach a real edge regardless of drag speed or prev/next calls.
100
+ const cloneBefore = rawSlides.map(s => {
101
+ const c = s.cloneNode(true)
102
+ c.setAttribute("aria-hidden", "true")
103
+ c.dataset.clone = "before"
104
+ return c
105
+ })
106
+ const cloneAfter = rawSlides.map(s => {
107
+ const c = s.cloneNode(true)
108
+ c.setAttribute("aria-hidden", "true")
109
+ c.dataset.clone = "after"
110
+ return c
111
+ })
112
+
113
+ // Prepend "before" in correct visual order, append "after"
114
+ ;[...cloneBefore].reverse().forEach(c => this.el.prepend(c))
115
+ cloneAfter.forEach(c => this.el.appendChild(c))
116
+
117
+ this._measure()
118
+
119
+ // x convention: translateX(-x). Start at setW so real slide 0 is visible.
120
+ // Track layout: [before clones 0..setW) [real slides setW..2setW) [after clones 2setW..3setW)
121
+ this.index = 0
122
+ this.current = this.setW
123
+ this.target = this.setW
124
+ this._draw(this.current)
125
+ }
126
+
127
+ // ─── Infinite wrap (runs every tick) ──────────────────────────────────────
128
+ // Silently teleport when position drifts outside the real-set zone.
129
+ // Because we cloned full sets on both sides this jump is always invisible.
130
+
131
+ _wrapInfinite() {
132
+ if (!this.setW) return
133
+ let shift = 0
134
+ if (this.current < 0) {
135
+ shift = Math.ceil(-this.current / this.setW) * this.setW
136
+ } else if (this.current >= 2 * this.setW) {
137
+ shift = -Math.floor((this.current - this.setW) / this.setW) * this.setW
138
+ }
139
+ if (shift !== 0) {
140
+ this.current += shift
141
+ this.target += shift
142
+ // Keep the drag reference in sync so the next onPointerMove doesn't
143
+ // compute a target relative to the pre-wrap baseline, which would cause
144
+ // the slider to snap back to the wrong position.
145
+ if (this.isDown) this.x.state += shift
146
+ }
147
+ }
148
+
149
+ // ─── Render ───────────────────────────────────────────────────────────────
150
+
151
+ _draw(x) {
152
+ this.el.style.transform = `translate3d(${-this._snap2px(x)}px, 0, 0)`
153
+ }
154
+
155
+ _updateProgress() {
156
+ if (!this.progressEl || this.infinite || !this.total) return
157
+ // Position-based so the bar reaches 100% exactly when current hits limit.
158
+ // Floor at 1/total so the bar is never empty on the first slide.
159
+ const min = 1 / this.total
160
+ const pos = this.limit > 0 ? this.current / this.limit : 1
161
+ const progress = Math.max(min, Math.min(1, pos))
162
+ this.progressEl.style.transformOrigin = "left center"
163
+ this.progressEl.style.transform = `scaleX(${progress})`
164
+ }
165
+
166
+ // ─── Tick ─────────────────────────────────────────────────────────────────
167
+
168
+ tick = () => {
169
+ if (!this.isActive) return
170
+
171
+ const prev = this.current
172
+ this.current = lerp(this.current, this.target, this.speed)
173
+
174
+ // Snap to exact value when close enough to avoid sub-pixel drift
175
+ if (Math.abs(this.current - this.target) < 0.01) {
176
+ this.current = this.target
177
+ }
178
+
179
+ // Infinite: wrap on every tick (not just when settled) so a fast drag
180
+ // that overshoots the clone zone is corrected immediately.
181
+ if (this.infinite) this._wrapInfinite()
182
+
183
+ if (this.current !== prev) {
184
+ this._draw(this.current)
185
+ this._updateProgress()
186
+ }
187
+ }
188
+
189
+ // ─── Snap ─────────────────────────────────────────────────────────────────
190
+
191
+ _snapToNearest() {
192
+ if (!this.slideWidth) return
193
+
194
+ if (this.infinite) {
195
+ // Round to nearest slide in ABSOLUTE (un-normalised) space.
196
+ // Do NOT clamp target back into [0, 2·setW) here — that would invert the
197
+ // lerp direction if the user released before current crossed the wrap
198
+ // boundary, causing a visible "all-slides-jump-backward" reset.
199
+ // Instead, keep target in whatever cycle it is; _wrapInfinite will shift
200
+ // both current and target together tick-by-tick as current naturally
201
+ // crosses each setW boundary — always seamless, always forward.
202
+ this.target = Math.round(this.target / this.slideWidth) * this.slideWidth
203
+ // Derive real 0-based index via modulo on normalised position
204
+ const normPos = ((this.target % this.setW) + this.setW) % this.setW
205
+ this.index = Math.round(normPos / this.slideWidth) % this.total
206
+ } else {
207
+ this.index = gsap.utils.clamp(0, this.total - 1, Math.round(this.target / this.slideWidth))
208
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
209
+ }
210
+
211
+ this._fireChange()
212
+ }
213
+
214
+ _fireChange() {
215
+ this._updateProgress()
216
+ if (!this.onSlideChange) return
217
+ this.onSlideChange({ index: this.index, total: this.total })
218
+ }
219
+
220
+ // ─── Public navigation ────────────────────────────────────────────────────
221
+
222
+ next = () => {
223
+ if (!this.isActive) return
224
+ if (this.infinite) {
225
+ this.target += this.slideWidth
226
+ this.index = (this.index + 1) % this.total
227
+ } else {
228
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index + 1)
229
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
230
+ }
231
+ this._fireChange()
232
+ }
233
+
234
+ prev = () => {
235
+ if (!this.isActive) return
236
+ if (this.infinite) {
237
+ this.target -= this.slideWidth
238
+ this.index = (this.index - 1 + this.total) % this.total
239
+ } else {
240
+ this.index = gsap.utils.clamp(0, this.total - 1, this.index - 1)
241
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
242
+ }
243
+ this._fireChange()
244
+ }
245
+
246
+ goTo(i) {
247
+ if (!this.isActive) return
248
+ const idx = gsap.utils.clamp(0, this.total - 1, i)
249
+ if (this.infinite) {
250
+ // Find the shortest delta from current position to target idx
251
+ const currentIdx = this.index
252
+ let delta = idx - currentIdx
253
+ if (Math.abs(delta) > this.total / 2) {
254
+ delta = delta > 0 ? delta - this.total : delta + this.total
255
+ }
256
+ this.target += delta * this.slideWidth
257
+ } else {
258
+ this.target = gsap.utils.clamp(0, this.limit, idx * this.slideWidth)
259
+ }
260
+ this.index = idx
261
+ this._fireChange()
262
+ }
263
+
264
+ // ─── Pointer events ───────────────────────────────────────────────────────
265
+
266
+ onPointerDown = e => {
267
+ if (!this.isActive) return
268
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
269
+ this.isDown = true
270
+ this.x.start = clientX
271
+ this.x.state = this.target
272
+ this.container.classList.add("-dragging")
273
+ }
274
+
275
+ onPointerMove = e => {
276
+ if (!this.isActive || !this.isDown) return
277
+ this.isDragging = true
278
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
279
+ const delta = this.x.start - clientX
280
+ let newTarget = this.x.state + delta
281
+
282
+ if (!this.infinite) {
283
+ newTarget = gsap.utils.clamp(0, this.limit, newTarget)
284
+ }
285
+
286
+ this.target = newTarget
287
+ }
288
+
289
+ onPointerUp = () => {
290
+ if (!this.isActive) return
291
+ this.isDown = false
292
+ this.container.classList.remove("-dragging")
293
+
294
+ if (this.isDragging && this.snap) {
295
+ this._snapToNearest()
296
+ }
297
+
298
+ this.isDragging = false
299
+ }
300
+
301
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
302
+
303
+ on() {
304
+ if (!this.isActive) return
305
+
306
+ this.container.addEventListener("mousedown", this.onPointerDown)
307
+ this.container.addEventListener("touchstart", this.onPointerDown, { passive: true })
308
+ window.addEventListener("mousemove", this.onPointerMove)
309
+ window.addEventListener("touchmove", this.onPointerMove, { passive: true })
310
+ window.addEventListener("mouseup", this.onPointerUp)
311
+ window.addEventListener("touchend", this.onPointerUp)
312
+
313
+ if (this.prevEl) this.prevEl.addEventListener("click", this.prev)
314
+ if (this.nextEl) this.nextEl.addEventListener("click", this.next)
315
+
316
+ emitter.on(EVENTS.APP_TICK, this.tick)
317
+ }
318
+
319
+ off() {
320
+ this.container?.removeEventListener("mousedown", this.onPointerDown)
321
+ this.container?.removeEventListener("touchstart", this.onPointerDown)
322
+ window.removeEventListener("mousemove", this.onPointerMove)
323
+ window.removeEventListener("touchmove", this.onPointerMove)
324
+ window.removeEventListener("mouseup", this.onPointerUp)
325
+ window.removeEventListener("touchend", this.onPointerUp)
326
+
327
+ if (this.prevEl) this.prevEl.removeEventListener("click", this.prev)
328
+ if (this.nextEl) this.nextEl.removeEventListener("click", this.next)
329
+
330
+ emitter.off(EVENTS.APP_TICK, this.tick)
331
+ }
332
+
333
+ resize = () => {
334
+ const wasActive = this.isActive
335
+ this.isActive = this._shouldActivate()
336
+
337
+ // Was active, now inactive (e.g. mobileOnly + resized to desktop)
338
+ if (wasActive && !this.isActive) {
339
+ this._reset()
340
+ this.off()
341
+ return
342
+ }
343
+
344
+ // Was inactive, now active (e.g. mobileOnly + resized to mobile)
345
+ if (!wasActive && this.isActive) {
346
+ this.setup()
347
+ this.on()
348
+ return
349
+ }
350
+
351
+ if (!this.isActive) return
352
+
353
+ if (this.infinite) {
354
+ // Rebuild clones and remeasure, keeping current real index visible
355
+ const savedIndex = this.index
356
+ this._setupInfinite()
357
+ this.index = savedIndex
358
+ this.current = this.target = this.setW + savedIndex * this.slideWidth
359
+ this._draw(this.current)
360
+ } else {
361
+ this._measure()
362
+ this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
363
+ this.current = this.target
364
+ this._draw(this.current)
365
+ this._updateProgress()
366
+ }
367
+ }
368
+
369
+ _reset() {
370
+ Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
371
+ this.el.style.transform = ""
372
+ this.current = this.target = 0
373
+ this.index = 0
374
+ }
375
+
376
+ destroy() {
377
+ this.off()
378
+ this._reset()
379
+ emitter.off(EVENTS.APP_RESIZE, this.resize)
380
+ this.container = null
381
+ this.el = null
382
+ }
383
+
384
+ init() {
385
+ this.setup()
386
+ this.on()
387
+ // APP_RESIZE is always registered so the slider can transition
388
+ // between active/inactive states when the breakpoint changes.
389
+ emitter.on(EVENTS.APP_RESIZE, this.resize)
390
+ }
391
+ }
@@ -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 }