chameleon-backgrounds 2.1.3 → 3.0.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.
@@ -14,7 +14,7 @@
14
14
  * |___/
15
15
  *
16
16
  * @module ChameleonBackgrounds
17
- * @version 2.1.3
17
+ * @version 3.0.0
18
18
  * @author Lennart van Ballegoij (https://weblenn.com/)
19
19
  * @license MIT
20
20
  * @see https://github.com/WebLenn/ChameleonBackgrounds
@@ -24,10 +24,15 @@
24
24
  */
25
25
 
26
26
  /**
27
+ * @typedef {Object} ChameleonImageConfig
28
+ * @property {string} url - The fallback URL.
29
+ * @property {string} [srcset] - Optional srcset.
30
+ * @property {string} [sizes] - Optional sizes.
31
+ *
27
32
  * @typedef {Object} ChameleonOptions
28
33
  * @property {string|HTMLElement} element - CSS selector or DOM element to attach to.
29
34
  * @property {'single'|'slider'} type - Background mode.
30
- * @property {string|string[]} src - Image URL(s). String for 'single', array for 'slider'.
35
+ * @property {string|string[]|ChameleonImageConfig|ChameleonImageConfig[]} src - Image URL(s) or config object(s).
31
36
  * @property {string} overlayColor - Overlay color (hex, rgb, rgba, hsl).
32
37
  * @property {string} [overlayImage] - Optional overlay pattern image URL.
33
38
  * @property {number} [minOverlay=0] - Minimum overlay opacity after fade (0–1).
@@ -35,6 +40,8 @@
35
40
  * @property {number} [sliderDuration=8000] - Time each slide is shown in milliseconds.
36
41
  * @property {boolean} [sliderLoop=false] - Whether the slider restarts after the last slide.
37
42
  * @property {'high'|'low'|'auto'} [fetchPriority='auto'] - fetchPriority for the first loaded image (e.g., 'high' for LCP images).
43
+ * @property {boolean} [lazyLoad=true] - Whether to wait until element is in viewport using IntersectionObserver.
44
+ * @property {'solid'|'crossfade'} [transitionMode='solid'] - Transition mode: 'solid' (fade to color) or 'crossfade' (fade between images).
38
45
  */
39
46
 
40
47
  class ChameleonBackgrounds {
@@ -50,6 +57,9 @@ class ChameleonBackgrounds {
50
57
  sliderDuration: 8000,
51
58
  sliderLoop: false,
52
59
  fetchPriority: 'auto',
60
+ lazyLoad: true,
61
+ transitionMode: 'solid',
62
+ respectReducedMotion: false,
53
63
  });
54
64
 
55
65
  // Legacy snake_case → camelCase alias map
@@ -61,8 +71,14 @@ class ChameleonBackgrounds {
61
71
  overlay_color: 'overlayColor',
62
72
  overlay_image: 'overlayImage',
63
73
  fetch_priority: 'fetchPriority',
74
+ lazy_load: 'lazyLoad',
75
+ transition_mode: 'transitionMode',
76
+ respect_reduced_motion: 'respectReducedMotion',
64
77
  });
65
78
 
79
+ /** @type {boolean} */
80
+ static #globalStylesInjected = false;
81
+
66
82
  /** @type {ChameleonOptions} */
67
83
  #options;
68
84
 
@@ -75,12 +91,18 @@ class ChameleonBackgrounds {
75
91
  /** @type {string} */
76
92
  #originalHTML;
77
93
 
78
- /** @type {HTMLStyleElement|null} */
79
- #styleElement = null;
80
-
81
94
  /** @type {number|null} */
82
95
  #sliderIntervalId = null;
83
96
 
97
+ /** @type {number} */
98
+ #currentSlideIndex = 0;
99
+
100
+ /** @type {boolean} */
101
+ #isPaused = false;
102
+
103
+ /** @type {IntersectionObserver|null} */
104
+ #observer = null;
105
+
84
106
  /** @type {boolean} */
85
107
  #destroyed = false;
86
108
 
@@ -116,12 +138,14 @@ class ChameleonBackgrounds {
116
138
  this.#sliderIntervalId = null;
117
139
  }
118
140
 
119
- // Remove injected style element
120
- if (this.#styleElement?.parentNode) {
121
- this.#styleElement.parentNode.removeChild(this.#styleElement);
122
- this.#styleElement = null;
141
+ if (this.#observer) {
142
+ this.#observer.disconnect();
143
+ this.#observer = null;
123
144
  }
124
145
 
146
+ // Clean up dynamic classes on host
147
+ this.#element.classList.remove(`cbg-host-${this.#uid}`, 'cbg-host');
148
+
125
149
  // Unwrap the original content instead of resetting innerHTML
126
150
  const wrapper = this.#element.querySelector(`#cbg-inner-${this.#uid}`);
127
151
  if (wrapper && wrapper.parentNode === this.#element) {
@@ -137,8 +161,16 @@ class ChameleonBackgrounds {
137
161
  loader.remove();
138
162
  }
139
163
 
140
- // Remove inline background-image
164
+ // Remove temporary crossfade elements
165
+ const crossfadeElements = this.#element.querySelectorAll(`.cbg-crossfade-${this.#uid}`);
166
+ crossfadeElements.forEach(el => el.remove());
167
+
168
+ // Remove inline CSS Variables and background-image
141
169
  this.#element.style.backgroundImage = '';
170
+ this.#element.style.removeProperty('--cbg-duration');
171
+ this.#element.style.removeProperty('--cbg-overlay-color');
172
+ this.#element.style.removeProperty('--cbg-overlay-image');
173
+ this.#element.style.removeProperty('--cbg-min-overlay');
142
174
  }
143
175
 
144
176
  /**
@@ -154,71 +186,145 @@ class ChameleonBackgrounds {
154
186
  // ---------------------------------------------------------------------------
155
187
 
156
188
  #init() {
157
- this.#injectStyles();
189
+ this.#injectGlobalStyles();
190
+ this.#updateCSSVariables();
158
191
  this.#buildDOM();
159
192
 
160
- // If the element is NOT <body>, wait for window load to start loading images.
161
- // For <body>, the loader is already visible, so we start immediately to avoid
162
- // the re-execution issue the legacy lib had with body scripts.
163
- if (this.#element.matches('body')) {
164
- // Use a microtask so the DOM changes above settle first.
165
- queueMicrotask(() => this.#retrieveBackground());
193
+ // Check for prefers-reduced-motion to auto-pause slider if enabled
194
+ if (this.#options.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
195
+ this.#isPaused = true;
196
+ }
197
+
198
+ if (this.#options.lazyLoad && 'IntersectionObserver' in window) {
199
+ this.#observer = new IntersectionObserver((entries) => {
200
+ entries.forEach((entry) => {
201
+ if (entry.isIntersecting) {
202
+ this.#observer.disconnect();
203
+ this.#observer = null;
204
+ this.#retrieveBackground();
205
+ }
206
+ });
207
+ });
208
+ this.#observer.observe(this.#element);
166
209
  } else {
167
- if (document.readyState === 'complete') {
168
- this.#retrieveBackground();
210
+ if (this.#element.matches('body')) {
211
+ queueMicrotask(() => this.#retrieveBackground());
169
212
  } else {
170
- window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
213
+ if (document.readyState === 'complete') {
214
+ this.#retrieveBackground();
215
+ } else {
216
+ window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
217
+ }
171
218
  }
172
219
  }
173
220
  }
174
221
 
222
+ // ---------------------------------------------------------------------------
223
+ // Play / Pause API
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Pause the slideshow.
228
+ */
229
+ pause() {
230
+ if (this.#destroyed || this.#options.type !== 'slider') return;
231
+ this.#isPaused = true;
232
+ if (this.#sliderIntervalId !== null) {
233
+ clearInterval(this.#sliderIntervalId);
234
+ this.#sliderIntervalId = null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Resume the slideshow.
240
+ */
241
+ play() {
242
+ if (this.#destroyed || this.#options.type !== 'slider') return;
243
+ this.#isPaused = false;
244
+ // Only restart if not currently running
245
+ if (this.#sliderIntervalId === null) {
246
+ this.#startSliderLoop();
247
+ }
248
+ }
249
+
175
250
  // ---------------------------------------------------------------------------
176
251
  // DOM Construction
177
252
  // ---------------------------------------------------------------------------
178
253
 
179
254
  /**
180
- * Inject a scoped <style> element into the <head>.
255
+ * Inject global styles once.
181
256
  */
182
- #injectStyles() {
183
- const uid = this.#uid;
184
- const selector = this.#options.element === 'body' || this.#element.matches('body')
185
- ? 'body'
186
- : this.#options.element;
187
- const duration = this.#options.transitionDuration / 1000; // ms → s
188
- const position = this.#element.matches('body') ? 'fixed' : 'absolute';
189
- const overlayBg = this.#options.overlayImage
190
- ? `url(${this.#options.overlayImage})`
191
- : 'none';
257
+ #injectGlobalStyles() {
258
+ if (ChameleonBackgrounds.#globalStylesInjected) return;
259
+ ChameleonBackgrounds.#globalStylesInjected = true;
192
260
 
193
261
  const css = `
194
- ${typeof selector === 'string' ? selector : `.cbg-host-${uid}`} {
262
+ .cbg-host {
195
263
  position: relative;
196
264
  }
197
-
198
- #cbg-inner-${uid} {
265
+ body.cbg-host {
266
+ /* body naturally acts as relative for background sizing if not explicitly positioned differently */
267
+ }
268
+ .cbg-inner {
199
269
  z-index: 2;
200
270
  position: relative;
201
271
  }
202
-
203
- .cbg-loader-${uid} {
272
+ .cbg-loader {
204
273
  height: 100%;
205
274
  width: 100%;
206
- position: ${position};
207
- background-image: ${overlayBg};
208
- background-color: ${this.#options.overlayColor};
275
+ position: absolute;
276
+ top: 0;
277
+ left: 0;
278
+ background-image: var(--cbg-overlay-image, none);
279
+ background-color: var(--cbg-overlay-color, transparent);
209
280
  opacity: 1;
210
281
  z-index: 1;
211
- transition: opacity ${duration}s ease;
282
+ transition: opacity var(--cbg-duration, 2s) ease;
283
+ pointer-events: none;
284
+ }
285
+ body.cbg-host > .cbg-loader {
286
+ position: fixed;
287
+ }
288
+ .cbg-crossfade-el {
289
+ position: absolute;
212
290
  top: 0;
213
291
  left: 0;
292
+ height: 100%;
293
+ width: 100%;
294
+ background-size: cover;
295
+ background-position: center;
296
+ background-repeat: no-repeat;
297
+ z-index: 0;
298
+ opacity: 0;
299
+ transition: opacity var(--cbg-duration, 2s) ease;
300
+ pointer-events: none;
301
+ }
302
+ body.cbg-host > .cbg-crossfade-el {
303
+ position: fixed;
214
304
  }
215
305
  `;
216
306
 
217
307
  const style = document.createElement('style');
218
- style.dataset.chameleonUid = uid;
308
+ style.id = 'chameleon-global-styles';
219
309
  style.textContent = css;
220
310
  document.head.appendChild(style);
221
- this.#styleElement = style;
311
+ }
312
+
313
+ /**
314
+ * Apply CSS custom properties to the host element.
315
+ */
316
+ #updateCSSVariables() {
317
+ this.#element.classList.add(`cbg-host-${this.#uid}`, 'cbg-host');
318
+ this.#element.style.setProperty('--cbg-duration', `${this.#options.transitionDuration / 1000}s`);
319
+ this.#element.style.setProperty('--cbg-overlay-color', this.#options.overlayColor);
320
+
321
+ if (this.#options.overlayImage) {
322
+ this.#element.style.setProperty('--cbg-overlay-image', `url(${this.#options.overlayImage})`);
323
+ } else {
324
+ this.#element.style.setProperty('--cbg-overlay-image', 'none');
325
+ }
326
+
327
+ this.#element.style.setProperty('--cbg-min-overlay', String(this.#options.minOverlay));
222
328
  }
223
329
 
224
330
  /**
@@ -229,6 +335,7 @@ class ChameleonBackgrounds {
229
335
 
230
336
  const wrapper = document.createElement('div');
231
337
  wrapper.id = `cbg-inner-${uid}`;
338
+ wrapper.classList.add('cbg-inner');
232
339
 
233
340
  // Move all child nodes from the element into the wrapper
234
341
  while (this.#element.firstChild) {
@@ -236,7 +343,7 @@ class ChameleonBackgrounds {
236
343
  }
237
344
 
238
345
  const loader = document.createElement('div');
239
- loader.classList.add(`cbg-loader-${uid}`);
346
+ loader.classList.add('cbg-loader', `cbg-loader-${uid}`);
240
347
 
241
348
  // Append wrapper and loader
242
349
  this.#element.appendChild(wrapper);
@@ -254,7 +361,8 @@ class ChameleonBackgrounds {
254
361
  if (this.#destroyed) return;
255
362
 
256
363
  if (this.#options.type === 'single') {
257
- this.#loadBackground(typeof this.#options.src === 'string' ? this.#options.src : this.#options.src[0]);
364
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
365
+ this.#loadBackground(singleSrc);
258
366
  } else if (this.#options.type === 'slider') {
259
367
  this.#startSlider();
260
368
  }
@@ -262,39 +370,87 @@ class ChameleonBackgrounds {
262
370
 
263
371
  /**
264
372
  * Preload an image and apply it as the element's background.
265
- * @param {string} src - Image URL to load.
373
+ * Handles both string URLs and responsive image objects ({ url, srcset, sizes }).
374
+ * @param {string|ChameleonImageConfig} srcConfig - Image configuration or URL.
266
375
  * @param {boolean} [isFirst=true] - Whether this is the first image (skips overlay reset).
376
+ * @param {boolean} [isCrossfade=false] - If true, handles true crossfading without solid color drop.
267
377
  * @returns {Promise<void>}
268
378
  */
269
- #loadBackground(src, isFirst = true) {
379
+ #loadBackground(srcConfig, isFirst = true, isCrossfade = false) {
270
380
  if (this.#destroyed) return Promise.resolve();
271
381
 
272
382
  return new Promise((resolve) => {
273
383
  const img = new Image();
384
+
385
+ let urlStr = srcConfig;
386
+ if (typeof srcConfig === 'object' && srcConfig.url) {
387
+ urlStr = srcConfig.url;
388
+ if (srcConfig.srcset) img.srcset = srcConfig.srcset;
389
+ if (srcConfig.sizes) img.sizes = srcConfig.sizes;
390
+ }
391
+
274
392
  if (isFirst && 'fetchPriority' in img && this.#options.fetchPriority !== 'auto') {
275
393
  img.fetchPriority = this.#options.fetchPriority;
276
394
  }
277
395
 
396
+ // Append to DOM to ensure viewport-based `sizes` calculate correctly in all browsers
397
+ img.style.position = 'absolute';
398
+ img.style.visibility = 'hidden';
399
+ img.style.width = '0';
400
+ img.style.height = '0';
401
+ this.#element.appendChild(img);
402
+
278
403
  img.onload = () => {
404
+ img.remove();
279
405
  if (this.#destroyed) return resolve();
280
406
 
281
- this.#element.style.backgroundImage = `url(${src})`;
282
- this.#element.style.backgroundSize = 'cover';
283
-
284
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
285
- if (loader) {
286
- loader.style.opacity = String(this.#options.minOverlay);
407
+ // `img.currentSrc` retrieves the actual srcset chosen file, otherwise fallback to `urlStr`
408
+ const finalUrl = img.currentSrc || urlStr;
409
+
410
+ if (isCrossfade && !isFirst) {
411
+ // --- Crossfade Transition Mode ---
412
+ const crossfadeDiv = document.createElement('div');
413
+ crossfadeDiv.classList.add('cbg-crossfade-el', `cbg-crossfade-${this.#uid}`);
414
+ crossfadeDiv.style.backgroundImage = `url("${finalUrl}")`;
415
+
416
+ // Insert right behind the loader (or directly at start of host)
417
+ this.#element.insertBefore(crossfadeDiv, this.#element.firstChild);
418
+
419
+ // Trigger layout so the transition works
420
+ void crossfadeDiv.offsetWidth;
421
+
422
+ crossfadeDiv.style.opacity = '1';
423
+
424
+ setTimeout(() => {
425
+ if (this.#destroyed) return resolve();
426
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
427
+ this.#element.style.backgroundSize = 'cover';
428
+ this.#element.style.backgroundRepeat = 'no-repeat';
429
+ crossfadeDiv.remove();
430
+ resolve();
431
+ }, this.#options.transitionDuration);
432
+
433
+ } else {
434
+ // --- Solid Transition Mode or First Load ---
435
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
436
+ this.#element.style.backgroundSize = 'cover';
437
+ this.#element.style.backgroundRepeat = 'no-repeat';
438
+
439
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
440
+ if (loader) {
441
+ loader.style.opacity = String(this.#options.minOverlay);
442
+ }
443
+ resolve();
287
444
  }
288
-
289
- resolve();
290
445
  };
291
446
 
292
447
  img.onerror = () => {
293
- console.warn(`[ChameleonBackgrounds] Failed to load image: ${src}`);
294
- resolve();
448
+ img.remove();
449
+ console.warn(`[ChameleonBackgrounds] Failed to load image:`, srcConfig);
450
+ resolve(); // resolve anyway to keep logic flowing
295
451
  };
296
452
 
297
- img.src = src;
453
+ img.src = urlStr;
298
454
  });
299
455
  }
300
456
 
@@ -309,7 +465,7 @@ class ChameleonBackgrounds {
309
465
 
310
466
  if (src !== undefined) {
311
467
  if (this.#options.type === 'slider' && typeof src === 'string') {
312
- // If the user passes a comma-separated string, split it. Otherwise wrap it.
468
+ // If the user passes a comma-separated string, split it.
313
469
  this.#options.src = src.includes(',') ? src.split(',').map(s => s.trim()) : [src];
314
470
  } else {
315
471
  this.#options.src = src;
@@ -322,23 +478,25 @@ class ChameleonBackgrounds {
322
478
  this.#sliderIntervalId = null;
323
479
  }
324
480
 
481
+ // For solid mode, we fade the loader up. For crossfade, we don't.
325
482
  const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
326
- if (loader) {
483
+ if (loader && this.#options.transitionMode === 'solid') {
327
484
  loader.style.opacity = '1';
328
485
  }
329
486
 
330
487
  return new Promise((resolve) => {
488
+ const waitTime = this.#options.transitionMode === 'solid' ? this.#options.transitionDuration : 0;
331
489
  setTimeout(() => {
332
490
  if (this.#destroyed) return resolve();
333
491
 
334
492
  if (this.#options.type === 'single') {
335
- const singleSrc = typeof this.#options.src === 'string' ? this.#options.src : this.#options.src[0];
336
- this.#loadBackground(singleSrc, false).then(resolve);
493
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
494
+ this.#loadBackground(singleSrc, false, this.#options.transitionMode === 'crossfade').then(resolve);
337
495
  } else if (this.#options.type === 'slider') {
338
496
  this.#startSlider();
339
497
  resolve();
340
498
  }
341
- }, this.#options.transitionDuration);
499
+ }, waitTime);
342
500
  });
343
501
  }
344
502
 
@@ -352,14 +510,11 @@ class ChameleonBackgrounds {
352
510
  const normalized = ChameleonBackgrounds.#normalizeOptions(newOptions);
353
511
  this.#options = { ...this.#options, ...normalized };
354
512
 
355
- // Remove old styles
356
- if (this.#styleElement?.parentNode) {
357
- this.#styleElement.parentNode.removeChild(this.#styleElement);
358
- this.#styleElement = null;
513
+ this.#updateCSSVariables();
514
+ // Restart slider if needed
515
+ if (this.#options.type === 'slider' && !this.#isPaused && !this.#sliderIntervalId) {
516
+ this.#startSlider();
359
517
  }
360
-
361
- // Inject updated styles
362
- this.#injectStyles();
363
518
  }
364
519
 
365
520
  // ---------------------------------------------------------------------------
@@ -379,52 +534,67 @@ class ChameleonBackgrounds {
379
534
  }
380
535
 
381
536
  // Load the first slide immediately
382
- let index = 0;
383
- this.#loadBackground(sources[index]).then(() => {
537
+ this.#currentSlideIndex = 0;
538
+ this.#loadBackground(sources[this.#currentSlideIndex]).then(() => {
384
539
  if (this.#destroyed) return;
385
540
 
386
- index = 1;
541
+ this.#currentSlideIndex = 1;
387
542
  if (sources.length === 1) return; // only one slide, nothing to rotate
388
543
 
389
- const interval = this.#options.sliderDuration + this.#options.transitionDuration * 2;
544
+ this.#startSliderLoop();
545
+ });
546
+ }
390
547
 
391
- this.#sliderIntervalId = setInterval(() => {
392
- if (this.#destroyed) {
393
- clearInterval(this.#sliderIntervalId);
394
- return;
395
- }
548
+ #startSliderLoop() {
549
+ const sources = this.#options.src;
396
550
 
397
- this.#cycleSliderSlide(sources[index]);
398
- index++;
551
+ // Parse as integers to prevent string concatenation bugs if users passed strings
552
+ const sDuration = parseInt(this.#options.sliderDuration, 10) || 8000;
553
+ const tDuration = parseInt(this.#options.transitionDuration, 10) || 2000;
554
+ const interval = sDuration + tDuration * (this.#options.transitionMode === 'solid' ? 2 : 1);
399
555
 
400
- if (index >= sources.length) {
401
- if (this.#options.sliderLoop) {
402
- index = 0;
403
- } else {
404
- clearInterval(this.#sliderIntervalId);
405
- this.#sliderIntervalId = null;
406
- }
556
+ this.#sliderIntervalId = setInterval(() => {
557
+ if (this.#destroyed || this.#isPaused) {
558
+ clearInterval(this.#sliderIntervalId);
559
+ this.#sliderIntervalId = null;
560
+ return;
561
+ }
562
+
563
+ this.#cycleSliderSlide(sources[this.#currentSlideIndex]);
564
+ this.#currentSlideIndex++;
565
+
566
+ if (this.#currentSlideIndex >= sources.length) {
567
+ if (this.#options.sliderLoop) {
568
+ this.#currentSlideIndex = 0;
569
+ } else {
570
+ clearInterval(this.#sliderIntervalId);
571
+ this.#sliderIntervalId = null;
407
572
  }
408
- }, interval);
409
- });
573
+ }
574
+ }, interval);
410
575
  }
411
576
 
412
577
  /**
413
578
  * Cycle to a specific slide without resetting the slider instance.
414
- * @param {string} src - The image URL to load.
579
+ * @param {string|ChameleonImageConfig} src - The image URL/config to load.
415
580
  */
416
581
  #cycleSliderSlide(src) {
417
582
  if (this.#destroyed) return;
418
583
 
419
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
420
- if (loader) {
421
- loader.style.opacity = '1';
422
- }
584
+ if (this.#options.transitionMode === 'solid') {
585
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
586
+ if (loader) {
587
+ loader.style.opacity = '1';
588
+ }
423
589
 
424
- setTimeout(() => {
425
- if (this.#destroyed) return;
426
- this.#loadBackground(src, false);
427
- }, this.#options.transitionDuration);
590
+ setTimeout(() => {
591
+ if (this.#destroyed) return;
592
+ this.#loadBackground(src, false, false);
593
+ }, this.#options.transitionDuration);
594
+ } else {
595
+ // Crossfade mode triggers immediately
596
+ this.#loadBackground(src, false, true);
597
+ }
428
598
  }
429
599
 
430
600
  // ---------------------------------------------------------------------------