@brandocms/jupiter 5.0.0-beta.1 → 5.0.0-beta.10

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.
@@ -77,9 +77,7 @@ function horizontalLoop(app, items, config) {
77
77
  let originalItemsWidth = 0 // Width of ONLY original items (for wrapping)
78
78
  let pixelsPerSecond = (config.speed || 1) * 100
79
79
  let animation = null
80
- let position = motionValue(0) // Source of truth for position
81
- let boundedPos = motionValue(0) // Bounded position (0 to originalItemsWidth)
82
- let lastBoundedValue = 0 // Track last value to detect wraps
80
+ let position = motionValue(0) // Source of truth for position (raw, unbounded)
83
81
  let originalItemCount = 0 // Track count of ORIGINAL items (before clones)
84
82
  let maxScrollPosition = 0 // For non-looping: max scroll where last item is at right edge
85
83
 
@@ -96,6 +94,7 @@ function horizontalLoop(app, items, config) {
96
94
 
97
95
  // Drag state and cleanup handlers
98
96
  let dragState = {}
97
+ let isDragging = false // Track if user is actively dragging (for wrap detection guard)
99
98
  let speedRampAnimation = null // Track speed ramp animation
100
99
  let inertiaAnimation = null // Track inertia animation
101
100
  let snapAnimation = null // Track snap animation
@@ -103,6 +102,10 @@ function horizontalLoop(app, items, config) {
103
102
  let positionUnsubscribe = null // Track position listener for cleanup
104
103
  let renderUnsubscribe = null // Track frame.render loop for cleanup
105
104
 
105
+ // Scroll direction tracking for wrap logic
106
+ let scrollDirection = 0 // -1 = backward, 0 = neutral, 1 = forward
107
+ let lastPositionForDirection = 0
108
+
106
109
  // Display elements for index/count
107
110
  let indexElements = []
108
111
  let countElements = []
@@ -163,24 +166,28 @@ function horizontalLoop(app, items, config) {
163
166
  // This prevents items from visibly moving to the back before they're off-screen
164
167
  const minRequiredWidth = containerWidth * 2.5 + maxItemWidth
165
168
 
166
- // Only replicate if needed
167
- if (totalWidth >= minRequiredWidth) {
168
- return
169
- }
170
-
171
169
  // Store original count to prevent exponential growth
172
170
  const originalItemCount = items.length
173
171
  const maxReplications = 10
174
172
  let count = 0
175
173
  let previousTotalWidth = totalWidth
176
174
 
177
- while (totalWidth < minRequiredWidth && count < maxReplications) {
175
+ // Always create at least TWO sets of clones - needed for starting at first clone position
176
+ // Then continue until we have enough width for seamless looping
177
+ while ((count < 2 || totalWidth < minRequiredWidth) && count < maxReplications) {
178
178
  // Clone ONLY original items
179
179
  for (let i = 0; i < originalItemCount; i++) {
180
180
  const clone = items[i].cloneNode(true)
181
181
  clone.setAttribute('data-looper-clone', 'true')
182
182
  container.appendChild(clone)
183
183
  items.push(clone)
184
+
185
+ // Force-load cloned lazyload elements without revealing them
186
+ // Sources are swapped immediately (no flash on wrap), but data-ll-loaded
187
+ // is not set — the reveal happens after the wrapper fade-in animation
188
+ if (app?.lazyload?.forceLoad) {
189
+ app.lazyload.forceLoad(clone, { reveal: false })
190
+ }
184
191
  }
185
192
 
186
193
  // Force layout recalculation
@@ -332,83 +339,48 @@ function horizontalLoop(app, items, config) {
332
339
 
333
340
  /**
334
341
  * Check item positions and wrap when needed
335
- * Container is animated DIRECTLY (not updated here!)
336
- * This function only reads position to determine wrapping
337
- * @param {number} pos - Current position value (can grow infinitely)
342
+ * Container uses RAW position - items wrap individually when far off-screen
343
+ * @param {number} rawPos - Current raw position value (unbounded)
338
344
  */
339
- function updateItemPositions(pos) {
340
- const containerElement = items[0].parentElement
341
-
342
- if (!shouldLoop) {
343
- // Non-looping: we'll handle this with direct animation
344
- return
345
- }
346
-
347
- // Calculate bounded position for checking item wrap points
348
- // Use same wrapping formula as frame.render to handle negative positions (reversed mode)
349
- const boundedPos = ((pos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
345
+ function updateItemPositions(rawPos) {
346
+ if (!shouldLoop) return
350
347
 
351
348
  // Initialize wrap offsets cache if needed
352
349
  if (itemWrapOffsets.length === 0) {
353
350
  itemWrapOffsets = new Array(items.length).fill(0)
354
351
  }
355
352
 
356
- // TICKER PATTERN: ONLY move ORIGINAL items to the back, NEVER touch clones!
357
- // This massively reduces DOM manipulation and style recalculation
358
- items.forEach((item, i) => {
359
- // Skip clones - they stay in natural flow! (use cached value for performance)
360
- if (isCloneCache[i]) {
361
- return
362
- }
353
+ // Items wrap by the full cycle distance (totalWidth) to maintain relative positions
354
+ // This keeps all items within viewing distance as position grows/shrinks
355
+ const cycleDistance = totalWidth
363
356
 
364
- // Calculate where this ORIGINAL item is on screen (relative to bounded container)
365
- const itemLeft = offsetLefts[i] - boundedPos
366
-
367
- // Original items only ever have -totalWidth, 0, or +totalWidth offset
368
- // This positions them AFTER all clones (not just after wrapping area)
369
- let newOffset = 0
370
-
371
- // Ticker boundary pattern: Check if item should be at the END or at the START
372
- // When container cycles (boundedPos wraps from ~originalItemsWidth to ~0),
373
- // items with large offsets get reset back to 0
374
-
375
- // Wrap distance includes the trailing gap for seamless cycling
376
- const wrapOffset = totalWidth + gap
377
-
378
- // Check if we're in the "reset zone" near wrap boundaries
379
- const nearForwardWrap = boundedPos > originalItemsWidth - gap
380
- const nearReverseWrap = boundedPos < gap
381
-
382
- // RESET: When in reset zone, reset items and SKIP wrap checks to avoid fighting
383
- if (nearForwardWrap && itemWrapOffsets[i] === wrapOffset) {
384
- // Container about to wrap (forward), reset items at END back to START
385
- newOffset = 0
386
- } else if (nearReverseWrap && itemWrapOffsets[i] === -wrapOffset) {
387
- // Container about to wrap (reverse), reset items at START back to END
388
- newOffset = 0
389
- } else if (nearForwardWrap || nearReverseWrap) {
390
- // In reset zone but item doesn't need reset → keep current offset
391
- newOffset = itemWrapOffsets[i]
392
- } else if (itemLeft < -(widths[i] + containerWidth * 0.5)) {
393
- // Item exited LEFT edge
394
- // Forward drag (low boundedPos): wrap to END
395
- // Backward drag (high boundedPos): don't wrap, clones fill in from right
396
- newOffset = boundedPos < originalItemsWidth / 2 ? wrapOffset : 0
397
- } else if (itemLeft > containerWidth + containerWidth * 0.5) {
398
- // Item exited RIGHT edge
399
- // This shouldn't happen much, but handle it
400
- newOffset = 0
401
- } else {
402
- // Keep current offset
403
- newOffset = itemWrapOffsets[i]
357
+ // Wrap threshold: when an item is more than half a cycle from view, wrap it
358
+ const wrapThreshold = cycleDistance / 2
359
+
360
+ for (let i = 0; i < items.length; i++) {
361
+ // Calculate this item's effective position (DOM position + wrap offset)
362
+ const effectivePos = offsetLefts[i] + itemWrapOffsets[i]
363
+
364
+ // Distance from current view position
365
+ // Positive = item is ahead (to the right), Negative = item is behind (to the left)
366
+ const distanceFromView = effectivePos - rawPos
367
+
368
+ let newOffset = itemWrapOffsets[i]
369
+
370
+ if (distanceFromView < -wrapThreshold) {
371
+ // Item is too far left (behind), wrap it forward (to the right)
372
+ newOffset = itemWrapOffsets[i] + cycleDistance
373
+ } else if (distanceFromView > wrapThreshold + containerWidth) {
374
+ // Item is too far right (ahead), wrap it backward (to the left)
375
+ newOffset = itemWrapOffsets[i] - cycleDistance
404
376
  }
405
377
 
406
- // ONLY update transform if the offset has changed!
378
+ // Only update DOM if offset changed
407
379
  if (newOffset !== itemWrapOffsets[i]) {
408
- item.style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
380
+ items[i].style.transform = newOffset !== 0 ? `translateX(${newOffset}px)` : 'none'
409
381
  itemWrapOffsets[i] = newOffset
410
382
  }
411
- })
383
+ }
412
384
  }
413
385
 
414
386
  /**
@@ -416,32 +388,58 @@ function horizontalLoop(app, items, config) {
416
388
  * @param {boolean} deep - Whether to rebuild animation (on resize)
417
389
  */
418
390
  function refresh(deep = false) {
419
- // Save progress to preserve position
420
- const progress = animation ? animation.time / animation.duration : 0
421
- const currentPos = position.get()
422
-
423
391
  // Pause animation if running
424
392
  const wasPlaying = animation && animation.speed !== 0
425
393
  if (animation) {
426
394
  animation.pause()
427
395
  }
428
396
 
397
+ if (deep && shouldLoop) {
398
+ // DEEP REFRESH: Reset everything for new dimensions
399
+ // Clear all item wrap offsets and transforms
400
+ for (let i = 0; i < items.length; i++) {
401
+ items[i].style.transform = 'none'
402
+ if (itemWrapOffsets[i] !== undefined) {
403
+ itemWrapOffsets[i] = 0
404
+ }
405
+ }
406
+ itemWrapOffsets = []
407
+ }
408
+
429
409
  // Remeasure everything
430
410
  populateWidths()
431
411
 
432
412
  if (deep) {
433
413
  // Check if we need to replicate more items
434
- const containerWidth = container.offsetWidth
414
+ const currentContainerWidth = container.offsetWidth
435
415
  const currentTotalWidth = getTotalWidthOfItems()
436
416
 
437
417
  // Use same 2.5x buffer as replication logic
438
- if (shouldLoop && currentTotalWidth < containerWidth * 2.5) {
418
+ if (shouldLoop && currentTotalWidth < currentContainerWidth * 2.5) {
439
419
  replicateItemsIfNeeded()
420
+ // Re-cache clone status for any new items
421
+ isCloneCache = items.map((item, i) => i >= originalItemCount)
440
422
  populateWidths()
441
423
  }
442
424
 
443
425
  populateSnapTimes()
444
426
 
427
+ // Reset position to start at first clone (like initial state)
428
+ if (shouldLoop && !config.centerSlide) {
429
+ position.set(originalItemsWidth)
430
+ lastPositionForDirection = originalItemsWidth
431
+ items[0].parentElement.style.transform = `translateX(${-originalItemsWidth}px)`
432
+ } else if (shouldLoop && config.centerSlide) {
433
+ // For center mode, go to middle slide
434
+ const middleIndex = Math.floor(originalItemCount / 2)
435
+ const targetTime = times[middleIndex]
436
+ const initialPos = targetTime * pixelsPerSecond
437
+ position.set(initialPos)
438
+ lastPositionForDirection = initialPos
439
+ items[0].parentElement.style.transform = `translateX(${-initialPos}px)`
440
+ curIndex = middleIndex
441
+ }
442
+
445
443
  // Recreate animation with new measurements
446
444
  if (shouldLoop && config.crawl) {
447
445
  // Stop old animation
@@ -449,7 +447,7 @@ function horizontalLoop(app, items, config) {
449
447
  animation.stop()
450
448
  }
451
449
 
452
- // Use startLoopAnimation for bounded position (no repeat: Infinity!)
450
+ // Recreate loop animation with new measurements
453
451
  animation = startLoopAnimation()
454
452
 
455
453
  // Restore playback state
@@ -472,19 +470,20 @@ function horizontalLoop(app, items, config) {
472
470
  })
473
471
 
474
472
  if (wasPlaying) {
475
- animation.time = progress * animation.duration
476
473
  animation.play()
477
474
  } else {
478
475
  animation.pause()
479
476
  }
480
477
  }
478
+
479
+ // Update index display
480
+ updateIndexDisplay()
481
481
  } else {
482
482
  // Light refresh - just update measurements
483
483
  populateSnapTimes()
484
+ // Update positions based on current scroll
485
+ updateItemPositions(position.get())
484
486
  }
485
-
486
- // Update positions based on current scroll
487
- updateItemPositions(currentPos)
488
487
  }
489
488
 
490
489
  /**
@@ -499,6 +498,46 @@ function horizontalLoop(app, items, config) {
499
498
  })
500
499
  }
501
500
 
501
+ /**
502
+ * Create and start the crawl animation loop
503
+ * Animates the position motionValue (frame.render loop applies to DOM)
504
+ */
505
+ function startLoopAnimation() {
506
+ if (!shouldLoop || !config.crawl) return null
507
+
508
+ const duration = originalItemsWidth / pixelsPerSecond
509
+ const currentPos = position.get()
510
+ // Reversed: crawl backwards (right-to-left), Normal: crawl forward (left-to-right)
511
+ const target = config.reversed
512
+ ? currentPos - originalItemsWidth
513
+ : currentPos + originalItemsWidth
514
+
515
+ // Animate the position motionValue
516
+ // frame.render loop will apply raw position to container and wrap items
517
+ animation = animate(position, target, {
518
+ duration,
519
+ repeat: Infinity,
520
+ ease: 'linear',
521
+ })
522
+
523
+ return animation
524
+ }
525
+
526
+ /**
527
+ * Stop the frame.render loop and cleanup listeners
528
+ * Called from init() and destroy()
529
+ */
530
+ function stopRenderLoop() {
531
+ if (renderUnsubscribe) {
532
+ // Unsubscribe from motionValue listener
533
+ if (renderUnsubscribe.positionUnsubscribe) {
534
+ renderUnsubscribe.positionUnsubscribe()
535
+ }
536
+ cancelFrame(renderUnsubscribe)
537
+ renderUnsubscribe = null
538
+ }
539
+ }
540
+
502
541
  /**
503
542
  * Initialize the loop animation
504
543
  */
@@ -519,89 +558,45 @@ function horizontalLoop(app, items, config) {
519
558
  // Set initial container position
520
559
  const containerElement = items[0].parentElement
521
560
  containerElement.style.willChange = 'transform'
522
- containerElement.style.transform = 'translateX(0px)'
523
561
 
524
- // Set up RAF loop to check item positions for wrapping
525
- // Frame.render loop to apply bounded position to DOM
562
+ // For looping (non-center mode): start viewing first CLONE, not originals
563
+ // This positions originals OFF-SCREEN LEFT so backward scroll reveals them smoothly
564
+ if (shouldLoop && !config.centerSlide) {
565
+ position.set(originalItemsWidth)
566
+ lastPositionForDirection = originalItemsWidth
567
+ containerElement.style.transform = `translateX(${-originalItemsWidth}px)`
568
+ } else {
569
+ containerElement.style.transform = 'translateX(0px)'
570
+ }
571
+
572
+ // Set up RAF loop to update container position and wrap items
573
+ // Uses RAW position (no modulo) for container transform
526
574
  // This is Motion's optimized render loop - prevents layout thrashing
527
575
  function startRenderLoop() {
528
576
  if (renderUnsubscribe) return // Already running
529
577
 
530
578
  const containerElement = items[0].parentElement
531
579
 
532
- // Set up boundedPos motionValue to automatically sync with position
533
- // This calculates the bounded position (0 to originalItemsWidth)
580
+ // Track scroll direction for wrap logic
534
581
  const positionUnsubscribe = position.on('change', latest => {
535
- const bounded = ((latest % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
536
- boundedPos.set(bounded)
537
- })
538
-
539
- // Detect when boundedPos wraps (makes large jump) and reset all items
540
- // This prevents stuck items during fast drags in either direction
541
- const boundedPosUnsubscribe = boundedPos.on('change', latest => {
542
- const delta = Math.abs(latest - lastBoundedValue)
543
-
544
- // If boundedPos jumped by more than 40% of the width, it wrapped
545
- // Using 40% instead of 50% to catch edge cases
546
- const didWrap = delta > originalItemsWidth * 0.4
547
-
548
- if (didWrap) {
549
- const direction =
550
- latest > lastBoundedValue ? 'backward (drag right)' : 'forward (drag left)'
551
-
552
- // Count how many items have non-zero offset before reset
553
- const itemsWithOffset = items.filter(
554
- (item, i) => !isCloneCache[i] && itemWrapOffsets[i] !== 0
555
- ).length
556
-
557
- // Reset ALL original items to 0 (both positive and negative offsets)
558
- items.forEach((item, i) => {
559
- if (!isCloneCache[i] && itemWrapOffsets[i] !== 0) {
560
- item.style.transform = 'none'
561
- itemWrapOffsets[i] = 0
562
- }
563
- })
564
-
565
- // CRITICAL: Sync unbounded position with bounded position to prevent
566
- // inertia calculation bugs when dragging RIGHT across boundaries
567
- // BUT only do this when NOT animating snap or nav, otherwise it interferes
568
- if (!snapAnimation && !navAnimation) {
569
- position.set(latest)
570
- } else {
571
- }
582
+ const delta = latest - lastPositionForDirection
583
+ if (Math.abs(delta) > 1) {
584
+ scrollDirection = delta > 0 ? 1 : -1
572
585
  }
573
-
574
- lastBoundedValue = latest
586
+ lastPositionForDirection = latest
575
587
  })
576
588
 
577
589
  renderUnsubscribe = frame.render(() => {
578
- // Read bounded position from motionValue
579
- const currentBoundedPos = boundedPos.get()
580
-
581
- // Apply bounded transform to container
582
- containerElement.style.transform = `translateX(${-currentBoundedPos}px)`
590
+ // Use RAW position (no bounded) for container
591
+ const currentPos = position.get()
592
+ containerElement.style.transform = `translateX(${-currentPos}px)`
583
593
 
584
- // Wrap items based on bounded position
585
- updateItemPositions(currentBoundedPos)
594
+ // Wrap items based on raw position
595
+ updateItemPositions(currentPos)
586
596
  }, true) // true = keep alive
587
597
 
588
- // Store unsubscribe functions for cleanup
598
+ // Store unsubscribe function for cleanup
589
599
  renderUnsubscribe.positionUnsubscribe = positionUnsubscribe
590
- renderUnsubscribe.boundedPosUnsubscribe = boundedPosUnsubscribe
591
- }
592
-
593
- function stopRenderLoop() {
594
- if (renderUnsubscribe) {
595
- // Unsubscribe from motionValue listeners
596
- if (renderUnsubscribe.positionUnsubscribe) {
597
- renderUnsubscribe.positionUnsubscribe()
598
- }
599
- if (renderUnsubscribe.boundedPosUnsubscribe) {
600
- renderUnsubscribe.boundedPosUnsubscribe()
601
- }
602
- cancelFrame(renderUnsubscribe)
603
- renderUnsubscribe = null
604
- }
605
600
  }
606
601
 
607
602
  // Start the frame.render loop
@@ -615,29 +610,6 @@ function horizontalLoop(app, items, config) {
615
610
  })
616
611
  }
617
612
 
618
- // Function to create and start the animation loop
619
- // Animates the position motionValue (frame.render loop applies to DOM)
620
- function startLoopAnimation() {
621
- if (!shouldLoop || !config.crawl) return null
622
-
623
- const duration = originalItemsWidth / pixelsPerSecond
624
- const currentPos = position.get()
625
- // Reversed: crawl backwards (right-to-left), Normal: crawl forward (left-to-right)
626
- const target = config.reversed
627
- ? currentPos - originalItemsWidth
628
- : currentPos + originalItemsWidth
629
-
630
- // Animate the position motionValue
631
- // frame.render loop will apply bounded position to DOM
632
- animation = animate(position, target, {
633
- duration,
634
- repeat: Infinity,
635
- ease: 'linear',
636
- })
637
-
638
- return animation
639
- }
640
-
641
613
  // Create animation by animating the position motionValue
642
614
  if (shouldLoop && config.crawl) {
643
615
  const duration = totalWidth / pixelsPerSecond
@@ -712,7 +684,7 @@ function horizontalLoop(app, items, config) {
712
684
  // Update display in real-time as position changes
713
685
  let lastDisplayedIndex = -1
714
686
  const updateIndexOnChange = () => {
715
- // Find closest slide to current bounded position
687
+ // Find closest slide to current position
716
688
  const closest = closestIndex(false)
717
689
 
718
690
  // Only update DOM if index changed (avoid thrashing)
@@ -725,12 +697,8 @@ function horizontalLoop(app, items, config) {
725
697
  }
726
698
  }
727
699
 
728
- // For looping, use boundedPos; for non-looping, use position directly
729
- if (shouldLoop) {
730
- boundedPos.on('change', updateIndexOnChange)
731
- } else {
732
- position.on('change', updateIndexOnChange)
733
- }
700
+ // Update index display whenever position changes (closestIndex normalizes internally)
701
+ position.on('change', updateIndexOnChange)
734
702
  }
735
703
  }
736
704
 
@@ -774,7 +742,7 @@ function horizontalLoop(app, items, config) {
774
742
  * Replaces GSAP Draggable with optimized pointer events
775
743
  */
776
744
  function setupDrag() {
777
- let isDragging = false
745
+ // isDragging is now module-level so wrap detection can see it
778
746
  let startX = 0
779
747
  let startPosition = 0
780
748
  let velocityTracker = [] // Track recent movements for velocity calculation
@@ -900,9 +868,9 @@ function horizontalLoop(app, items, config) {
900
868
  const newPosition = startPosition + deltaX
901
869
 
902
870
  // Update position motionValue
903
- // frame.render loop will apply bounded transform to DOM
871
+ // frame.render loop applies raw position to container
904
872
  if (shouldLoop) {
905
- // For looping, allow unbounded position (frame.render will bound it)
873
+ // For looping, position grows freely - items wrap as groups
906
874
  position.set(newPosition)
907
875
  } else {
908
876
  // For non-looping, clamp position to maxScrollPosition (last item at right edge)
@@ -1136,7 +1104,7 @@ function horizontalLoop(app, items, config) {
1136
1104
  snapAnimation = null
1137
1105
  // Update display to reflect landed position
1138
1106
  updateIndexDisplay()
1139
- if (config.crawl && animation) {
1107
+ if (config.crawl) {
1140
1108
  resumeCrawl()
1141
1109
  }
1142
1110
  })
@@ -1165,7 +1133,8 @@ function horizontalLoop(app, items, config) {
1165
1133
  const currentPos = position.get()
1166
1134
 
1167
1135
  // Calculate position within current cycle (using originalItemsWidth)
1168
- const cyclePos = currentPos % originalItemsWidth
1136
+ // Use proper modulo for negative positions (dragging right/backward)
1137
+ const cyclePos = ((currentPos % originalItemsWidth) + originalItemsWidth) % originalItemsWidth
1169
1138
  const remainingDist = originalItemsWidth - cyclePos
1170
1139
  const remainingDuration = remainingDist / pixelsPerSecond
1171
1140
 
@@ -1490,13 +1459,18 @@ function horizontalLoop(app, items, config) {
1490
1459
  closestIndex(true)
1491
1460
  let nextIndex = curIndex + 1
1492
1461
 
1493
- // Non-looping: reset to start when reaching the end
1462
+ // Non-looping: clamp at boundaries
1494
1463
  if (!shouldLoop) {
1495
1464
  const currentPos = position.get()
1496
1465
  const atEnd = currentPos >= maxScrollPosition - 1
1497
1466
 
1498
1467
  if (nextIndex >= originalItemCount || atEnd) {
1499
- nextIndex = 0
1468
+ // Autoplay resets to start, user navigation clamps
1469
+ if (options.autoplay) {
1470
+ nextIndex = 0
1471
+ } else {
1472
+ return // Clamp - do nothing at boundary
1473
+ }
1500
1474
  }
1501
1475
  }
1502
1476
 
@@ -1512,9 +1486,14 @@ function horizontalLoop(app, items, config) {
1512
1486
  closestIndex(true)
1513
1487
  let prevIndex = curIndex - 1
1514
1488
 
1515
- // Non-looping: reset to end when at the start
1489
+ // Non-looping: clamp at boundaries
1516
1490
  if (!shouldLoop && prevIndex < 0) {
1517
- prevIndex = originalItemCount - 1
1491
+ // Autoplay resets to end, user navigation clamps
1492
+ if (options.autoplay) {
1493
+ prevIndex = originalItemCount - 1
1494
+ } else {
1495
+ return // Clamp - do nothing at boundary
1496
+ }
1518
1497
  }
1519
1498
 
1520
1499
  return toIndex(prevIndex, vars)
@@ -1717,8 +1696,29 @@ export default class Looper {
1717
1696
  })
1718
1697
  }
1719
1698
 
1720
- // Fade in the WRAPPER (not the outer element!)
1721
- if (wrapper) {
1699
+ // Reveal lazyload images: immediately reveal off-screen items (no visible transition),
1700
+ // defer reveal of viewport items until after wrapper fade-in for a nice per-image fade
1701
+ if (this.app?.lazyload && wrapper) {
1702
+ const wrapperRect = wrapper.getBoundingClientRect()
1703
+ const pictures = Dom.all(wrapper, '[data-ll-srcset]')
1704
+ const viewportPictures = []
1705
+
1706
+ pictures.forEach(picture => {
1707
+ const rect = picture.getBoundingClientRect()
1708
+ const inViewport = rect.right > wrapperRect.left && rect.left < wrapperRect.right
1709
+ if (inViewport) {
1710
+ viewportPictures.push(picture)
1711
+ } else {
1712
+ this.app.lazyload.revealPicture(picture)
1713
+ }
1714
+ })
1715
+
1716
+ // Fade in the WRAPPER (not the outer element!)
1717
+ animate(wrapper, { opacity: 1 }, { duration: 0.5, delay: 0.5, ease: 'easeOut' })
1718
+ .then(() => {
1719
+ viewportPictures.forEach(picture => this.app.lazyload.revealPicture(picture))
1720
+ })
1721
+ } else if (wrapper) {
1722
1722
  animate(wrapper, { opacity: 1 }, { duration: 0.5, delay: 0.5, ease: 'easeOut' })
1723
1723
  }
1724
1724
 
@@ -81,7 +81,7 @@ import { set } from '../../utils/motion-helpers'
81
81
  const DEFAULT_EVENTS = {
82
82
  onPin: (h) => {
83
83
  animate(h.el, {
84
- yPercent: '0'
84
+ y: '0%'
85
85
  }, {
86
86
  duration: 0.35,
87
87
  ease: 'easeOut'
@@ -91,7 +91,7 @@ const DEFAULT_EVENTS = {
91
91
  onUnpin: (h) => {
92
92
  h._hiding = true
93
93
  animate(h.el, {
94
- yPercent: '-100'
94
+ y: '-100%'
95
95
  }, {
96
96
  duration: 0.25,
97
97
  ease: 'easeIn'
@@ -158,14 +158,14 @@ const DEFAULT_OPTIONS = {
158
158
  canvas: window,
159
159
  intersects: null,
160
160
  beforeEnter: (h) => {
161
- set(h.el, { yPercent: -100 })
161
+ set(h.el, { y: '-100%' })
162
162
  set(h.lis, { opacity: 0 })
163
163
  },
164
164
 
165
165
  enter: (h) => {
166
166
  // Header slides down
167
167
  animate(h.el, {
168
- yPercent: 0
168
+ y: '0%'
169
169
  }, {
170
170
  duration: 1,
171
171
  delay: h.opts.enterDelay,
@@ -33,6 +33,8 @@ export function set(target, values) {
33
33
  transformProps.push(`translateX(${typeof value === 'number' ? value + 'px' : value})`)
34
34
  } else if (key === 'y') {
35
35
  transformProps.push(`translateY(${typeof value === 'number' ? value + 'px' : value})`)
36
+ } else if (key === 'yPercent') {
37
+ transformProps.push(`translateY(${value}%)`)
36
38
  } else if (key === 'scale') {
37
39
  transformProps.push(`scale(${value})`)
38
40
  } else if (key === 'scaleX') {
package/types/index.d.ts CHANGED
@@ -3,4 +3,4 @@ import imagesAreLoaded from './utils/imagesAreLoaded';
3
3
  import loadScript from './utils/loadScript';
4
4
  import prefersReducedMotion from './utils/prefersReducedMotion';
5
5
  import rafCallback from './utils/rafCallback';
6
- export { Application, Breakpoints, Cookies, CoverOverlay, Dataloader, Dom, DoubleHeader, Draggable, Dropdown, EqualHeightElements, EqualHeightImages, Events, FixedHeader, FooterReveal, Parallax, HeroSlider, HeroVideo, Lazyload, Lightbox, Links, Looper, Marquee, MobileMenu, Moonwalk, Popover, Popup, ScrollSpy, StackedBoxes, StickyHeader, Toggler, Typography, imageIsLoaded, imagesAreLoaded, loadScript, prefersReducedMotion, rafCallback, _defaultsDeep, gsap, CSSPlugin, ScrollToPlugin, ScrollTrigger, SplitText, InertiaPlugin };
6
+ export { Application, Breakpoints, Cookies, CoverOverlay, Dataloader, Dom, DoubleHeader, Dropdown, EqualHeightElements, EqualHeightImages, Events, FixedHeader, FooterReveal, Parallax, HeroSlider, HeroVideo, Lazyload, Lightbox, Links, Looper, Marquee, MobileMenu, Moonwalk, Popover, Popup, ScrollSpy, StackedBoxes, StickyHeader, Toggler, Typography, imageIsLoaded, imagesAreLoaded, loadScript, prefersReducedMotion, rafCallback, _defaultsDeep, animate, scroll, stagger, motionValue };