scroll-snap-kit 1.1.0 → 2.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.
package/src/utils.js CHANGED
@@ -334,4 +334,410 @@ export function easeScroll(target, options = {}) {
334
334
  }
335
335
  requestAnimationFrame(step);
336
336
  });
337
+ }
338
+
339
+ // ─────────────────────────────────────────────
340
+ // v1.2 FEATURES
341
+ // ─────────────────────────────────────────────
342
+
343
+ /**
344
+ * scrollSequence — run multiple easeScroll animations one after another.
345
+ * Returns { promise, cancel } — cancel() aborts mid-sequence.
346
+ *
347
+ * @example
348
+ * const { promise, cancel } = scrollSequence([
349
+ * { target: '#intro', duration: 600 },
350
+ * { target: '#features', duration: 800, pause: 400 },
351
+ * { target: '#pricing', duration: 600, easing: Easings.easeOutElastic },
352
+ * ])
353
+ */
354
+ export function scrollSequence(steps) {
355
+ let cancelled = false;
356
+ const promise = (async () => {
357
+ for (const step of steps) {
358
+ if (cancelled) break;
359
+ const { target, duration = 600, easing = Easings.easeInOutCubic, offset = 0, pause = 0 } = step;
360
+ await easeScroll(target, { duration, easing, offset });
361
+ if (pause > 0 && !cancelled) await new Promise(res => setTimeout(res, pause));
362
+ }
363
+ })();
364
+ return { promise, cancel: () => { cancelled = true; } };
365
+ }
366
+
367
+ /**
368
+ * parallax — attach a parallax scroll effect to one or more elements.
369
+ * speed < 1 = slower (background), speed > 1 = faster (foreground), speed < 0 = reverse.
370
+ * Returns a destroy / cleanup function.
371
+ *
372
+ * @example
373
+ * const destroy = parallax('.hero-bg', { speed: 0.4 })
374
+ * const destroy = parallax('.clouds', { speed: -0.2, axis: 'x' })
375
+ */
376
+ export function parallax(targets, options = {}) {
377
+ const { speed = 0.5, axis = 'y', container } = options;
378
+ let els;
379
+ if (typeof targets === 'string') els = Array.from(document.querySelectorAll(targets));
380
+ else if (targets instanceof Element) els = [targets];
381
+ else els = Array.from(targets);
382
+ if (!els.length) { console.warn('[scroll-snap-kit] parallax: no elements found'); return () => { }; }
383
+
384
+ const origins = els.map(el => ({
385
+ el,
386
+ originY: el.getBoundingClientRect().top + window.scrollY,
387
+ originX: el.getBoundingClientRect().left + window.scrollX,
388
+ }));
389
+
390
+ let rafId = null;
391
+ function update() {
392
+ const scrollY = container ? container.scrollTop : window.scrollY;
393
+ const scrollX = container ? container.scrollLeft : window.scrollX;
394
+ origins.forEach(({ el, originY, originX }) => {
395
+ const dy = (scrollY - (originY - window.innerHeight / 2)) * (speed - 1);
396
+ const dx = (scrollX - (originX - window.innerWidth / 2)) * (speed - 1);
397
+ if (axis === 'y') el.style.transform = `translateY(${dy}px)`;
398
+ else if (axis === 'x') el.style.transform = `translateX(${dx}px)`;
399
+ else el.style.transform = `translate(${dx}px, ${dy}px)`;
400
+ });
401
+ rafId = null;
402
+ }
403
+ const handler = () => { if (!rafId) rafId = requestAnimationFrame(update); };
404
+ const t = container || window;
405
+ t.addEventListener('scroll', handler, { passive: true });
406
+ update();
407
+ return () => {
408
+ t.removeEventListener('scroll', handler);
409
+ if (rafId) cancelAnimationFrame(rafId);
410
+ origins.forEach(({ el }) => { el.style.transform = ''; });
411
+ };
412
+ }
413
+
414
+ /**
415
+ * scrollProgress — track how far the user has scrolled through a specific element (0→1).
416
+ * 0 = element top just entered the viewport, 1 = element bottom just left the viewport.
417
+ * Returns a cleanup function.
418
+ *
419
+ * @example
420
+ * const stop = scrollProgress('#article', progress => {
421
+ * bar.style.width = `${progress * 100}%`
422
+ * })
423
+ */
424
+ export function scrollProgress(element, callback, options = {}) {
425
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
426
+ if (!el) { console.warn('[scroll-snap-kit] scrollProgress: element not found'); return () => { }; }
427
+ const { offset = 0 } = options;
428
+ function calculate() {
429
+ const rect = el.getBoundingClientRect();
430
+ const wh = window.innerHeight;
431
+ const total = rect.height + wh;
432
+ const passed = wh - rect.top + offset;
433
+ callback(Math.min(1, Math.max(0, passed / total)));
434
+ }
435
+ calculate();
436
+ window.addEventListener('scroll', calculate, { passive: true });
437
+ window.addEventListener('resize', calculate);
438
+ return () => {
439
+ window.removeEventListener('scroll', calculate);
440
+ window.removeEventListener('resize', calculate);
441
+ };
442
+ }
443
+
444
+ /**
445
+ * snapToSection — after scrolling stops, auto-snap to the nearest section.
446
+ * Returns a destroy function.
447
+ *
448
+ * @example
449
+ * const destroy = snapToSection('section[id]', { delay: 150, offset: -70 })
450
+ */
451
+ export function snapToSection(sections, options = {}) {
452
+ const { delay = 150, offset = 0, duration = 500, easing = Easings.easeInOutCubic } = options;
453
+ const els = typeof sections === 'string'
454
+ ? Array.from(document.querySelectorAll(sections))
455
+ : Array.from(sections);
456
+ if (!els.length) { console.warn('[scroll-snap-kit] snapToSection: no sections found'); return () => { }; }
457
+
458
+ let timer = null, snapping = false;
459
+ const handler = () => {
460
+ clearTimeout(timer);
461
+ timer = setTimeout(async () => {
462
+ if (snapping) return;
463
+ snapping = true;
464
+ const scrollMid = window.scrollY + window.innerHeight / 2;
465
+ let closest = els[0], minDist = Infinity;
466
+ els.forEach(el => {
467
+ const mid = el.offsetTop + el.offsetHeight / 2;
468
+ const d = Math.abs(mid - scrollMid);
469
+ if (d < minDist) { minDist = d; closest = el; }
470
+ });
471
+ await easeScroll(closest, { duration, easing, offset });
472
+ snapping = false;
473
+ }, delay);
474
+ };
475
+ window.addEventListener('scroll', handler, { passive: true });
476
+ return () => { clearTimeout(timer); window.removeEventListener('scroll', handler); };
477
+ }
478
+
479
+ // ─────────────────────────────────────────────
480
+ // v2.0 FEATURES
481
+ // ─────────────────────────────────────────────
482
+
483
+ /**
484
+ * scrollReveal — animate elements in as they scroll into view.
485
+ * Supports fade, slide (up/down/left/right), scale, and combinations.
486
+ * Uses IntersectionObserver for performance.
487
+ * Returns a destroy function.
488
+ *
489
+ * @param {string|Element|Element[]} targets
490
+ * @param {{ effect?: 'fade'|'slide-up'|'slide-down'|'slide-left'|'slide-right'|'scale'|'fade-scale',
491
+ * duration?: number, delay?: number, easing?: string,
492
+ * threshold?: number, once?: boolean, distance?: string }} options
493
+ * @returns {() => void}
494
+ *
495
+ * @example
496
+ * const destroy = scrollReveal('.card', { effect: 'slide-up', duration: 600, delay: 100 })
497
+ */
498
+ export function scrollReveal(targets, options = {}) {
499
+ const {
500
+ effect = 'fade',
501
+ duration = 500,
502
+ delay = 0,
503
+ easing = 'cubic-bezier(0.4, 0, 0.2, 1)',
504
+ threshold = 0.15,
505
+ once = true,
506
+ distance = '24px',
507
+ } = options;
508
+
509
+ const els = typeof targets === 'string'
510
+ ? Array.from(document.querySelectorAll(targets))
511
+ : targets instanceof Element ? [targets] : Array.from(targets);
512
+
513
+ if (!els.length) { console.warn('[scroll-snap-kit] scrollReveal: no elements found'); return () => { }; }
514
+
515
+ const hiddenStyles = {
516
+ 'fade': { opacity: '0' },
517
+ 'slide-up': { opacity: '0', transform: `translateY(${distance})` },
518
+ 'slide-down': { opacity: '0', transform: `translateY(-${distance})` },
519
+ 'slide-left': { opacity: '0', transform: `translateX(${distance})` },
520
+ 'slide-right': { opacity: '0', transform: `translateX(-${distance})` },
521
+ 'scale': { opacity: '0', transform: 'scale(0.9)' },
522
+ 'fade-scale': { opacity: '0', transform: `scale(0.95) translateY(${distance})` },
523
+ };
524
+
525
+ const hidden = hiddenStyles[effect] || hiddenStyles['fade'];
526
+
527
+ // Save original styles and apply hidden state
528
+ els.forEach((el, i) => {
529
+ el._ssk_origin = { transition: el.style.transition, ...Object.fromEntries(Object.keys(hidden).map(k => [k, el.style[k]])) };
530
+ Object.assign(el.style, hidden);
531
+ el.style.transition = `opacity ${duration}ms ${easing} ${delay + i * 0}ms, transform ${duration}ms ${easing} ${delay}ms`;
532
+ });
533
+
534
+ const obs = new IntersectionObserver((entries) => {
535
+ entries.forEach(entry => {
536
+ if (entry.isIntersecting) {
537
+ const el = entry.target;
538
+ const i = els.indexOf(el);
539
+ setTimeout(() => {
540
+ el.style.opacity = '';
541
+ el.style.transform = '';
542
+ }, i * (delay || 0));
543
+ if (once) obs.unobserve(el);
544
+ } else if (!once) {
545
+ Object.assign(entry.target.style, hidden);
546
+ }
547
+ });
548
+ }, { threshold });
549
+
550
+ els.forEach(el => obs.observe(el));
551
+
552
+ return () => {
553
+ obs.disconnect();
554
+ els.forEach(el => {
555
+ if (el._ssk_origin) {
556
+ el.style.transition = el._ssk_origin.transition;
557
+ el.style.opacity = el._ssk_origin.opacity || '';
558
+ el.style.transform = el._ssk_origin.transform || '';
559
+ delete el._ssk_origin;
560
+ }
561
+ });
562
+ };
563
+ }
564
+
565
+ /**
566
+ * scrollTimeline — drive CSS custom properties (variables) from scroll position,
567
+ * letting you animate anything via CSS using `var(--scroll-progress)` etc.
568
+ * Also supports directly animating numeric CSS properties on elements.
569
+ *
570
+ * @param {Array<{
571
+ * property: string, // CSS custom property name e.g. '--hero-opacity'
572
+ * from: number, // value at scrollStart
573
+ * to: number, // value at scrollEnd
574
+ * unit?: string, // CSS unit e.g. 'px', '%', 'deg', 'rem' (default: '')
575
+ * scrollStart?: number, // page scroll Y to begin (default: 0)
576
+ * scrollEnd?: number, // page scroll Y to end (default: document height)
577
+ * target?: Element|string, // element to set the property on (default: document.documentElement)
578
+ * }>} tracks
579
+ * @returns {() => void} cleanup function
580
+ *
581
+ * @example
582
+ * const stop = scrollTimeline([
583
+ * { property: '--hero-opacity', from: 1, to: 0, scrollStart: 0, scrollEnd: 400 },
584
+ * { property: '--nav-blur', from: 0, to: 16, unit: 'px', scrollStart: 0, scrollEnd: 200 },
585
+ * ])
586
+ */
587
+ export function scrollTimeline(tracks, options = {}) {
588
+ if (!Array.isArray(tracks) || !tracks.length) {
589
+ console.warn('[scroll-snap-kit] scrollTimeline: tracks must be a non-empty array');
590
+ return () => { };
591
+ }
592
+
593
+ const resolved = tracks.map(t => ({
594
+ ...t,
595
+ unit: t.unit ?? '',
596
+ scrollStart: t.scrollStart ?? 0,
597
+ scrollEnd: t.scrollEnd ?? (document.body.scrollHeight - window.innerHeight),
598
+ target: typeof t.target === 'string'
599
+ ? document.querySelector(t.target)
600
+ : (t.target ?? document.documentElement),
601
+ }));
602
+
603
+ let rafId = null;
604
+
605
+ function update() {
606
+ const scrollY = window.scrollY;
607
+ resolved.forEach(({ property, from, to, unit, scrollStart, scrollEnd, target }) => {
608
+ const range = scrollEnd - scrollStart;
609
+ const progress = range <= 0 ? 1 : Math.min(1, Math.max(0, (scrollY - scrollStart) / range));
610
+ const value = from + (to - from) * progress;
611
+ target.style.setProperty(property, `${value}${unit}`);
612
+ });
613
+ rafId = null;
614
+ }
615
+
616
+ const handler = () => { if (!rafId) rafId = requestAnimationFrame(update); };
617
+ window.addEventListener('scroll', handler, { passive: true });
618
+ update();
619
+
620
+ return () => {
621
+ window.removeEventListener('scroll', handler);
622
+ if (rafId) cancelAnimationFrame(rafId);
623
+ resolved.forEach(({ property, target }) => target.style.removeProperty(property));
624
+ };
625
+ }
626
+
627
+ /**
628
+ * infiniteScroll — fire a callback when the user scrolls near the bottom of the
629
+ * page (or a scrollable container), typically used to load more content.
630
+ *
631
+ * Automatically re-arms itself after the callback resolves (if it returns a Promise)
632
+ * or after a configurable cooldown, so rapid triggers are prevented.
633
+ *
634
+ * @param {() => void | Promise<void>} callback
635
+ * @param {{ threshold?: number, cooldown?: number, container?: Element }} options
636
+ * @returns {() => void} cleanup / stop function
637
+ *
638
+ * @example
639
+ * const stop = infiniteScroll(async () => {
640
+ * const items = await fetchMoreItems()
641
+ * appendItems(items)
642
+ * }, { threshold: 300 })
643
+ */
644
+ export function infiniteScroll(callback, options = {}) {
645
+ const { threshold = 200, cooldown = 500, container } = options;
646
+ let loading = false;
647
+
648
+ const getRemaining = () => {
649
+ if (container) {
650
+ return container.scrollHeight - container.scrollTop - container.clientHeight;
651
+ }
652
+ return document.body.scrollHeight - window.scrollY - window.innerHeight;
653
+ };
654
+
655
+ const handler = async () => {
656
+ if (loading) return;
657
+ if (getRemaining() <= threshold) {
658
+ loading = true;
659
+ try {
660
+ await Promise.resolve(callback());
661
+ } finally {
662
+ setTimeout(() => { loading = false; }, cooldown);
663
+ }
664
+ }
665
+ };
666
+
667
+ const target = container || window;
668
+ target.addEventListener('scroll', handler, { passive: true });
669
+ handler(); // check immediately in case already near bottom
670
+
671
+ return () => target.removeEventListener('scroll', handler);
672
+ }
673
+
674
+ /**
675
+ * scrollTrap — contain scroll within a specific element (like a modal or drawer),
676
+ * preventing the background page from scrolling while the element is open.
677
+ * Handles mouse wheel, touch, and keyboard arrow/space/pageup/pagedown events.
678
+ *
679
+ * Returns a release function.
680
+ *
681
+ * @param {Element | string} element
682
+ * @param {{ allowKeys?: boolean }} options
683
+ * @returns {() => void} release function
684
+ *
685
+ * @example
686
+ * const release = scrollTrap(document.querySelector('.modal'))
687
+ * // …later, when modal closes:
688
+ * release()
689
+ */
690
+ export function scrollTrap(element, options = {}) {
691
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
692
+ if (!(el instanceof Element)) {
693
+ console.warn('[scroll-snap-kit] scrollTrap: element not found');
694
+ return () => { };
695
+ }
696
+
697
+ const { allowKeys = true } = options;
698
+
699
+ const canScrollUp = () => el.scrollTop > 0;
700
+ const canScrollDown = () => el.scrollTop < el.scrollHeight - el.clientHeight;
701
+
702
+ // Wheel handler — block wheel events that would escape the element
703
+ const onWheel = (e) => {
704
+ const goingDown = e.deltaY > 0;
705
+ if (goingDown && !canScrollDown()) { e.preventDefault(); return; }
706
+ if (!goingDown && !canScrollUp()) { e.preventDefault(); return; }
707
+ };
708
+
709
+ // Touch handler
710
+ let touchStartY = 0;
711
+ const onTouchStart = (e) => { touchStartY = e.touches[0].clientY; };
712
+ const onTouchMove = (e) => {
713
+ const dy = touchStartY - e.touches[0].clientY;
714
+ if (dy > 0 && !canScrollDown()) { e.preventDefault(); return; }
715
+ if (dy < 0 && !canScrollUp()) { e.preventDefault(); return; }
716
+ };
717
+
718
+ // Key handler
719
+ const SCROLL_KEYS = { ArrowUp: -40, ArrowDown: 40, PageUp: -300, PageDown: 300, ' ': 300 };
720
+ const onKeyDown = (e) => {
721
+ const delta = SCROLL_KEYS[e.key];
722
+ if (!delta) return;
723
+ if (!el.contains(document.activeElement) && document.activeElement !== el) return;
724
+ e.preventDefault();
725
+ el.scrollTop += delta;
726
+ };
727
+
728
+ el.addEventListener('wheel', onWheel, { passive: false });
729
+ el.addEventListener('touchstart', onTouchStart, { passive: true });
730
+ el.addEventListener('touchmove', onTouchMove, { passive: false });
731
+ if (allowKeys) document.addEventListener('keydown', onKeyDown);
732
+
733
+ // Also lock the body
734
+ lockScroll();
735
+
736
+ return () => {
737
+ el.removeEventListener('wheel', onWheel);
738
+ el.removeEventListener('touchstart', onTouchStart);
739
+ el.removeEventListener('touchmove', onTouchMove);
740
+ if (allowKeys) document.removeEventListener('keydown', onKeyDown);
741
+ unlockScroll();
742
+ };
337
743
  }