rune-scroller 0.0.2 → 0.1.1

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
@@ -15,7 +15,7 @@
15
15
  - ✅ **Svelte 5 Runes** : `$state`, `$props()` with snippets
16
16
  - ✅ **Zero Dependencies** : Pure Svelte 5 + IntersectionObserver
17
17
  - ✅ **Native Performance** : GPU-accelerated CSS animations
18
- - ✅ **26+ Animations** : Fade, Zoom, Flip, Slide, Bounce, and more
18
+ - ✅ **14 Built-in Animations** : Fade (5), Zoom (5), Flip (2), Slide Rotate, Bounce
19
19
  - ✅ **TypeScript** : Full type coverage with strict mode
20
20
  - ✅ **Customizable** : Duration, delay, threshold, offset per element
21
21
  - ✅ **Play Once or Repeat** : Control animation behavior
@@ -55,19 +55,24 @@ For a typical SvelteKit app:
55
55
  ## 📦 Project Structure
56
56
 
57
57
  ```
58
- rune-scroller/
58
+ rune-scroller-lib/
59
59
  ├── src/lib/
60
60
  │ ├── Rs.svelte # Main animation component (one-time or repeat)
61
- │ ├── BaseAnimated.svelte # Base component (internal)
62
- │ ├── useIntersection.svelte.ts # IntersectionObserver composable
63
- │ ├── animations.ts # Animation configuration
64
- │ ├── animations.css # Animation styles
61
+ │ ├── BaseAnimated.svelte # Base animation implementation
62
+ │ ├── runeScroller.svelte.ts # Sentinel-based animation action (recommended)
63
+ │ ├── useIntersection.svelte.ts # IntersectionObserver composables
64
+ │ ├── animate.svelte.ts # Animation action for direct DOM control
65
+ │ ├── animations.ts # Animation configuration & validation
66
+ │ ├── animations.css # Animation styles (14 animations)
67
+ │ ├── animations.test.ts # Animation configuration tests
68
+ │ ├── scroll-animate.test.ts # Component behavior tests
65
69
  │ └── index.ts # Library entry point
66
- ├── src/routes/
67
- ├── +layout.svelte
68
- ├── +page.svelte # Demo/landing page
69
- │ └── test/+page.svelte # Test page
70
- └── package.json
70
+ ├── dist/ # Built library (created by pnpm build)
71
+ ├── package.json # npm package configuration
72
+ ├── svelte.config.js # SvelteKit configuration
73
+ ├── vite.config.ts # Vite build configuration
74
+ ├── tsconfig.json # TypeScript configuration
75
+ └── eslint.config.js # ESLint configuration
71
76
  ```
72
77
 
73
78
  ---
@@ -153,6 +158,107 @@ The `Rs` component is the unified API for all scroll animations:
153
158
 
154
159
  ---
155
160
 
161
+ ## 🎯 Sentinel-Based Animation Triggering with `runeScroller`
162
+
163
+ For more precise control over animation timing, use the `runeScroller` action. This approach uses an invisible **sentinel element** positioned below your content to trigger animations at exactly the right moment.
164
+
165
+ ### Why Sentinels?
166
+
167
+ - **Accurate Timing** - Instead of triggering when the element enters, sentinel triggers slightly earlier
168
+ - **Consistent Behavior** - Same timing across all screen sizes and content heights
169
+ - **Simple API** - No complex offset calculations needed
170
+ - **Performance** - Minimal overhead, pure CSS animations
171
+
172
+ ### How Sentinels Work
173
+
174
+ 1. An invisible 20px sentinel element is automatically placed **below** your animated element
175
+ 2. When the sentinel enters the viewport, it triggers the animation
176
+ 3. This ensures content animates in perfectly as it becomes visible
177
+
178
+ ```svelte
179
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
180
+ <!-- Your content here -->
181
+ <!-- Invisible sentinel is automatically placed below -->
182
+ </div>
183
+ ```
184
+
185
+ ### Basic Usage
186
+
187
+ ```svelte
188
+ <script>
189
+ import { runeScroller } from 'rune-scroller';
190
+ import 'rune-scroller/animations.css';
191
+ </script>
192
+
193
+ <!-- Simple fade in with sentinel triggering -->
194
+ <div use:runeScroller={{ animation: 'fade-in' }}>
195
+ <h2>Animated Heading</h2>
196
+ <p>Animates when sentinel enters viewport</p>
197
+ </div>
198
+
199
+ <!-- With duration control -->
200
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 1500 }}>
201
+ <div class="card">Smooth animation</div>
202
+ </div>
203
+ ```
204
+
205
+ ### Sentinel-Based Examples
206
+
207
+ **Staggered animations with sentinels:**
208
+
209
+ ```svelte
210
+ <script>
211
+ import { runeScroller } from 'rune-scroller';
212
+ </script>
213
+
214
+ <div class="grid">
215
+ {#each items as item, i}
216
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 800 }}>
217
+ <h3>{item.title}</h3>
218
+ <p>{item.description}</p>
219
+ </div>
220
+ {/each}
221
+ </div>
222
+ ```
223
+
224
+ **Hero section with sentinel triggering:**
225
+
226
+ ```svelte
227
+ <div use:runeScroller={{ animation: 'fade-in-down', duration: 1000 }}>
228
+ <h1>Welcome to Our Site</h1>
229
+ </div>
230
+
231
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 1200 }}>
232
+ <p>Engaging content appears as you scroll</p>
233
+ </div>
234
+
235
+ <div use:runeScroller={{ animation: 'zoom-in', duration: 1000 }}>
236
+ <button class="cta">Get Started</button>
237
+ </div>
238
+ ```
239
+
240
+ ### `runeScroller` Options
241
+
242
+ ```typescript
243
+ interface RuneScrollerOptions {
244
+ animation?: AnimationType; // Animation type (e.g., 'fade-in-up')
245
+ duration?: number; // Duration in milliseconds (default: 2000)
246
+ repeat?: boolean; // Repeat animation on each scroll (default: false)
247
+ }
248
+ ```
249
+
250
+ ### Comparing: `Rs` Component vs `runeScroller` Action
251
+
252
+ | Feature | `Rs` Component | `runeScroller` Action |
253
+ |---------|---|---|
254
+ | **Usage** | `<Rs>` wrapper | `use:` directive |
255
+ | **Triggering** | IntersectionObserver on element | IntersectionObserver on sentinel |
256
+ | **Timing Control** | offset, threshold props | Automatic sentinel placement |
257
+ | **Repeat Support** | Yes (via `repeat` prop) | Yes (via `repeat` option) |
258
+ | **Best For** | Complex layouts, component isolation | Direct DOM control, simple/mixed elements |
259
+
260
+ ---
261
+
156
262
  ## ⚙️ Component Props
157
263
 
158
264
  ### Rs Component
@@ -303,7 +409,7 @@ Fades in while moving right 100px.
303
409
 
304
410
  ---
305
411
 
306
- ### Zoom (2 variants)
412
+ ### Zoom (5 variants)
307
413
 
308
414
  #### `zoom-in`
309
415
 
@@ -331,6 +437,45 @@ Scales from 150% to 100% while fading in.
331
437
  </Rs>
332
438
  ```
333
439
 
440
+ #### `zoom-in-up`
441
+
442
+ Scales from 50% while translating up 50px.
443
+
444
+ ```svelte
445
+ <Rs animation="zoom-in-up">
446
+ <div class="card">
447
+ <h2>Zoom In Up</h2>
448
+ <p>Grows while moving up</p>
449
+ </div>
450
+ </Rs>
451
+ ```
452
+
453
+ #### `zoom-in-left`
454
+
455
+ Scales from 50% while translating left 50px.
456
+
457
+ ```svelte
458
+ <Rs animation="zoom-in-left">
459
+ <div class="card">
460
+ <h2>Zoom In Left</h2>
461
+ <p>Grows while moving left</p>
462
+ </div>
463
+ </Rs>
464
+ ```
465
+
466
+ #### `zoom-in-right`
467
+
468
+ Scales from 50% while translating right 50px.
469
+
470
+ ```svelte
471
+ <Rs animation="zoom-in-right">
472
+ <div class="card">
473
+ <h2>Zoom In Right</h2>
474
+ <p>Grows while moving right</p>
475
+ </div>
476
+ </Rs>
477
+ ```
478
+
334
479
  ---
335
480
 
336
481
  ### Flip (2 variants)
@@ -480,65 +625,115 @@ Animate cards with progressive delays:
480
625
 
481
626
  ---
482
627
 
483
- ## 🎨 Theming
628
+ ## 🔧 Composables & Actions
484
629
 
485
- The project includes a modern **Granite + Electric Blue** theme in `src/lib/viking-theme.css`.
630
+ ### runeScroller (Recommended)
486
631
 
487
- ### Color Palette
632
+ The `runeScroller` action provides sentinel-based animation triggering for precise timing control:
488
633
 
489
- ```css
490
- --granite-dark: #0f1419;
491
- --granite-medium: #1a1f2e;
492
- --granite-light: #252d3d;
493
- --electric-blue: #00d9ff;
494
- --text-primary: #f0f2f5;
495
- --text-secondary: #a8b0be;
634
+ ```typescript
635
+ function runeScroller(
636
+ element: HTMLElement,
637
+ options?: {
638
+ animation?: AnimationType; // Animation type
639
+ duration?: number; // Duration in ms (default: 2000)
640
+ repeat?: boolean; // Repeat animation on each scroll (default: false)
641
+ }
642
+ ): { update?: (newOptions) => void; destroy?: () => void }
496
643
  ```
497
644
 
498
- ### Using Animations with Custom CSS Classes
645
+ **Key Features:**
646
+ - Automatically creates an invisible 20px sentinel element below your content
647
+ - Triggers animation when sentinel enters viewport
648
+ - Provides consistent timing across all screen sizes
649
+ - Minimal configuration needed
650
+ - Supports both one-time and repeating animations
651
+
652
+ **Basic Example (One-time animation):**
499
653
 
500
654
  ```svelte
501
655
  <script>
502
- import Rs from '$lib/Rs.svelte';
656
+ import { runeScroller } from 'rune-scroller';
503
657
  </script>
504
658
 
505
- <!-- Apply custom classes to animated content -->
506
- <Rs animation="fade-in-up" class="my-custom-card">
507
- <div>
508
- <h2>Title</h2>
509
- <p>Content</p>
510
- </div>
511
- </Rs>
659
+ <!-- Animation plays once when sentinel enters viewport -->
660
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
661
+ Animated content with sentinel-based triggering
662
+ </div>
663
+ ```
664
+
665
+ **Repeating Animation:**
666
+
667
+ ```svelte
668
+ <!-- Animation repeats each time sentinel enters viewport -->
669
+ <div use:runeScroller={{ animation: 'bounce-in', duration: 800, repeat: true }}>
670
+ This animates every time you scroll past it
671
+ </div>
672
+ ```
673
+
674
+ **Complete Examples:**
675
+
676
+ ```svelte
677
+ <script>
678
+ import { runeScroller } from 'rune-scroller';
679
+ </script>
680
+
681
+ <!-- Fade in once on scroll -->
682
+ <div use:runeScroller={{ animation: 'fade-in', duration: 600 }}>
683
+ <h2>Section Title</h2>
684
+ <p>Fades in when scrolled into view</p>
685
+ </div>
512
686
 
513
- <!-- Combine with HTML attributes like data-* -->
514
- <Rs animation="zoom-in" data-section="features" id="feature-1">
687
+ <!-- Zoom in with longer duration -->
688
+ <div use:runeScroller={{ animation: 'zoom-in-up', duration: 1200 }}>
515
689
  <div class="card">
516
- <h3>Feature</h3>
690
+ <h3>Card Title</h3>
691
+ <p>Zooms in from below</p>
517
692
  </div>
518
- </Rs>
693
+ </div>
694
+
695
+ <!-- Repeating animation for interactive effect -->
696
+ <div use:runeScroller={{ animation: 'bounce-in', duration: 700, repeat: true }}>
697
+ <button class="interactive-button">Bounces on each scroll</button>
698
+ </div>
699
+
700
+ <!-- Complex staggered layout -->
701
+ <div class="grid">
702
+ {#each items as item, i}
703
+ <div use:runeScroller={{ animation: 'fade-in-up', duration: 800 }}>
704
+ <h3>{item.title}</h3>
705
+ <p>{item.description}</p>
706
+ </div>
707
+ {/each}
708
+ </div>
519
709
  ```
520
710
 
521
- ---
711
+ **When to use:**
712
+ - ✅ Simple element animations
713
+ - ✅ Consistent timing across layouts
714
+ - ✅ Minimal overhead applications
715
+ - ✅ Both one-time and repeating animations
716
+ - ❌ Complex layout with component isolation (use `Rs` component instead)
522
717
 
523
- ## 🔧 Composables
718
+ ---
524
719
 
525
720
  ### useIntersectionOnce
526
721
 
527
- For animations that play only once (used by `ScrollAnimate`):
722
+ For one-time animations:
528
723
 
529
724
  ```typescript
530
725
  function useIntersectionOnce(options?: {
531
726
  threshold?: number;
532
727
  rootMargin?: string;
533
728
  root?: Element | null;
534
- });
729
+ }): { element: HTMLElement | null; isVisible: boolean }
535
730
  ```
536
731
 
537
- Returns `{ element, isVisible }` — bind `element` to your target, `isVisible` becomes `true` once.
732
+ Returns `{ element, isVisible }` — bind `element` to your target, `isVisible` becomes `true` once, then observer unobserves.
538
733
 
539
734
  ### useIntersection
540
735
 
541
- For repeating animations (used by `AnimatedElements`):
736
+ For repeating animations:
542
737
 
543
738
  ```typescript
544
739
  function useIntersection(
@@ -548,22 +743,62 @@ function useIntersection(
548
743
  root?: Element | null;
549
744
  },
550
745
  onVisible?: (isVisible: boolean) => void
551
- );
746
+ ): { element: HTMLElement | null; isVisible: boolean }
552
747
  ```
553
748
 
554
- Returns `{ element, isVisible }` — `isVisible` toggles on each scroll.
749
+ Returns `{ element, isVisible }` — `isVisible` toggles `true`/`false` on each scroll pass.
750
+
751
+ ### animate Action
752
+
753
+ For direct DOM animation control without component wrapper:
754
+
755
+ ```typescript
756
+ function animate(
757
+ node: HTMLElement,
758
+ options?: {
759
+ animation?: AnimationType; // Default: 'fade-in'
760
+ duration?: number; // Default: 800
761
+ delay?: number; // Default: 0
762
+ offset?: number; // Optional trigger offset
763
+ threshold?: number; // Default: 0
764
+ rootMargin?: string; // Optional custom margin
765
+ }
766
+ ): { update?: (newOptions) => void; destroy?: () => void }
767
+ ```
768
+
769
+ **Example:**
770
+
771
+ ```svelte
772
+ <script>
773
+ import { animate } from 'rune-scroller';
774
+ </script>
775
+
776
+ <div use:animate={{ animation: 'fade-in-up', duration: 1000, delay: 200 }}>
777
+ Animated content
778
+ </div>
779
+ ```
555
780
 
556
781
  ---
557
782
 
558
783
  ## 🏗️ Architecture
559
784
 
560
- ### Animation System
785
+ ### Core Layer Architecture
561
786
 
562
- 1. **animations.ts** - Configuration and validation
563
- 2. **animations.css** - Reusable animation styles (exported via npm)
564
- 3. **useIntersection.svelte.ts** - IntersectionObserver logic
565
- 4. **BaseAnimated.svelte** - Base animation implementation
566
- 5. **Rs.svelte** - Main component (one-time or repeating based on `repeat` prop)
787
+ **Bottom Layer - Browser APIs & Utilities:**
788
+ 1. **animations.ts** - Animation type definitions, validation, and utilities
789
+ 2. **dom-utils.svelte.ts** - Reusable DOM manipulation utilities (CSS variables, animation setup, sentinel creation)
790
+ 3. **useIntersection.svelte.ts** - IntersectionObserver composables for element visibility detection
791
+
792
+ **Middle Layer - Base Implementation:**
793
+ 4. **animate.svelte.ts** - Action for direct DOM node animation control
794
+ 5. **runeScroller.svelte.ts** - **Recommended** - Sentinel-based action for precise animation timing
795
+ 6. **BaseAnimated.svelte** - Base component handling intersection observer + animation logic
796
+
797
+ **Top Layer - Consumer API:**
798
+ 7. **Rs.svelte** - Main unified component (supports one-time & repeating via `repeat` prop)
799
+
800
+ **Styles:**
801
+ - **animations.css** - All animation keyframes & styles (14 animations, GPU-accelerated)
567
802
 
568
803
  ### Key Principles
569
804
 
@@ -571,6 +806,45 @@ Returns `{ element, isVisible }` — `isVisible` toggles on each scroll.
571
806
  - **CSS-Based** : Animations use CSS transforms + transitions (hardware-accelerated)
572
807
  - **Type-Safe** : Full TypeScript support
573
808
  - **Composable** : Use hooks directly or wrapped components
809
+ - **DRY (Don't Repeat Yourself)** : Utility functions eliminate code duplication
810
+ - **Optimal DOM Manipulation** : Uses `cssText` for efficient single-statement styling
811
+
812
+ ---
813
+
814
+ ## 🚀 Optimizations
815
+
816
+ ### Recent Improvements (v1.1.0)
817
+
818
+ **DOM Utility Extraction**
819
+ - Extracted repeated DOM manipulation patterns into reusable utilities (`dom-utils.svelte.ts`)
820
+ - `setCSSVariables()` - Centralizes CSS custom property management
821
+ - `setupAnimationElement()` - Consistent animation class/attribute setup
822
+ - `createSentinel()` - Optimized sentinel creation using single `cssText` statement
823
+ - **Result**: Reduced code duplication, improved maintainability, cleaner codebase
824
+
825
+ **Memory Leak Fixes**
826
+ - Fixed potential memory leaks in repeat mode by tracking observer connection state
827
+ - Observer now properly disconnects in destroy lifecycle
828
+ - Prevents accumulation of observers on long-scroll pages
829
+ - **Result**: Better performance on content-heavy sites with many animations
830
+
831
+ **Observer Logic Improvements**
832
+ - Fixed `animate.svelte.ts` to properly handle dynamic threshold/rootMargin changes
833
+ - Observer now recreates when trigger options change at runtime
834
+ - Maintains correct state throughout component lifecycle
835
+ - **Result**: More reliable dynamic animation updates
836
+
837
+ **Bundle Size Optimization**
838
+ - Updated `.npmignore` to exclude test files from npm distribution
839
+ - Removes `*.test.ts`, `*.test.js` and built test files
840
+ - **Result**: ~3.6 KB reduction in package size
841
+
842
+ ### Performance Impact
843
+
844
+ - **Code Size**: Reduced duplication without sacrificing readability
845
+ - **Runtime Performance**: Fewer DOM operations via optimized `cssText` usage
846
+ - **Memory Efficiency**: Proper observer cleanup prevents memory leaks
847
+ - **Bundle Size**: Test files excluded from distribution
574
848
 
575
849
  ---
576
850
 
@@ -596,10 +870,22 @@ pnpm dev
596
870
  # Type checking
597
871
  pnpm check
598
872
 
873
+ # Type checking in watch mode
874
+ pnpm check:watch
875
+
599
876
  # Format code
600
877
  pnpm format
601
878
 
602
- # Preview build
879
+ # Lint code
880
+ pnpm lint
881
+
882
+ # Build library for npm
883
+ pnpm build
884
+
885
+ # Run tests
886
+ pnpm test
887
+
888
+ # Preview built library
603
889
  pnpm preview
604
890
  ```
605
891
 
@@ -608,9 +894,10 @@ pnpm preview
608
894
  ## 📝 Notes
609
895
 
610
896
  - **Why "Rune"?** Svelte 5 uses **Runes** (`$state`, `$props()`) as core reactivity primitives
611
- - **Theme Name** : Granite + Electric Blue = Modern, minimalist aesthetic
612
- - **No Dependencies** : Pure Svelte 5 + Browser APIs
897
+ - **Zero Dependencies** : Pure Svelte 5 + Native Browser APIs (IntersectionObserver)
613
898
  - **Extensible** : Add new animations by extending `animations.ts` and `animations.css`
899
+ - **Library Only** : This is the library repository. The demo website is in `rune-scroller-site`
900
+ - **Published as npm Package** : `rune-scroller` on npm registry
614
901
 
615
902
  ---
616
903
 
@@ -1,4 +1,5 @@
1
1
  import { calculateRootMargin } from './animations';
2
+ import { setCSSVariables, setupAnimationElement } from './dom-utils.svelte';
2
3
  /**
3
4
  * Svelte action for scroll animations
4
5
  * Triggers animation once when element enters viewport
@@ -11,17 +12,15 @@ import { calculateRootMargin } from './animations';
11
12
  * ```
12
13
  */
13
14
  export const animate = (node, options = {}) => {
14
- const { animation = 'fade-in', duration = 800, delay = 0, offset, threshold = 0, rootMargin } = options;
15
+ let { animation = 'fade-in', duration = 800, delay = 0, offset, threshold = 0, rootMargin } = options;
15
16
  // Calculate rootMargin from offset (0-100%)
16
- const finalRootMargin = calculateRootMargin(offset, rootMargin);
17
- // Set CSS custom properties for timing
18
- node.style.setProperty('--duration', `${duration}ms`);
19
- node.style.setProperty('--delay', `${delay}ms`);
20
- // Add base animation class and data attribute
21
- node.classList.add('scroll-animate');
22
- node.setAttribute('data-animation', animation);
17
+ let finalRootMargin = calculateRootMargin(offset, rootMargin);
18
+ // Setup animation with utilities
19
+ setupAnimationElement(node, animation);
20
+ setCSSVariables(node, duration, delay);
23
21
  // Track if animation has been triggered
24
22
  let animated = false;
23
+ let observerConnected = true;
25
24
  // Create IntersectionObserver for one-time animation
26
25
  const observer = new IntersectionObserver((entries) => {
27
26
  entries.forEach((entry) => {
@@ -31,6 +30,7 @@ export const animate = (node, options = {}) => {
31
30
  animated = true;
32
31
  // Stop observing after animation triggers
33
32
  observer.unobserve(node);
33
+ observerConnected = false;
34
34
  }
35
35
  });
36
36
  }, {
@@ -40,20 +40,40 @@ export const animate = (node, options = {}) => {
40
40
  observer.observe(node);
41
41
  return {
42
42
  update(newOptions) {
43
- const { duration: newDuration = 800, delay: newDelay = 0, animation: newAnimation } = newOptions;
43
+ const { duration: newDuration, delay: newDelay, animation: newAnimation, offset: newOffset, threshold: newThreshold, rootMargin: newRootMargin } = newOptions;
44
44
  // Update CSS properties
45
- if (newDuration !== duration) {
46
- node.style.setProperty('--duration', `${newDuration}ms`);
45
+ if (newDuration !== undefined) {
46
+ duration = newDuration;
47
+ setCSSVariables(node, duration, newDelay ?? delay);
47
48
  }
48
- if (newDelay !== delay) {
49
- node.style.setProperty('--delay', `${newDelay}ms`);
49
+ if (newDelay !== undefined && newDelay !== delay) {
50
+ delay = newDelay;
51
+ setCSSVariables(node, duration, delay);
50
52
  }
51
53
  if (newAnimation && newAnimation !== animation) {
54
+ animation = newAnimation;
52
55
  node.setAttribute('data-animation', newAnimation);
53
56
  }
57
+ // Recreate observer if threshold or rootMargin changed
58
+ if (newThreshold !== undefined || newOffset !== undefined || newRootMargin !== undefined) {
59
+ if (observerConnected) {
60
+ observer.disconnect();
61
+ observerConnected = false;
62
+ }
63
+ threshold = newThreshold ?? threshold;
64
+ offset = newOffset ?? offset;
65
+ rootMargin = newRootMargin ?? rootMargin;
66
+ finalRootMargin = calculateRootMargin(offset, rootMargin);
67
+ if (!animated) {
68
+ observer.observe(node);
69
+ observerConnected = true;
70
+ }
71
+ }
54
72
  },
55
73
  destroy() {
56
- observer.disconnect();
74
+ if (observerConnected) {
75
+ observer.disconnect();
76
+ }
57
77
  }
58
78
  };
59
79
  };
@@ -8,7 +8,7 @@
8
8
  export type AnimationType = 'fade-in' | 'fade-in-up' | 'fade-in-down' | 'fade-in-left' | 'fade-in-right' | 'zoom-in' | 'zoom-out' | 'zoom-in-up' | 'zoom-in-left' | 'zoom-in-right' | 'flip' | 'flip-x' | 'slide-rotate' | 'bounce-in';
9
9
  /**
10
10
  * Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
11
- * @param offset - Viewport offset (0-100). 0 = bottom trigger, 100 = top trigger
11
+ * @param offset - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
12
12
  * @param rootMargin - Custom rootMargin string (takes precedence over offset)
13
13
  * @returns rootMargin string for IntersectionObserver
14
14
  */
@@ -3,7 +3,7 @@
3
3
  */
4
4
  /**
5
5
  * Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
6
- * @param offset - Viewport offset (0-100). 0 = bottom trigger, 100 = top trigger
6
+ * @param offset - Viewport offset (0-100). 0 = bottom of viewport touches top of element, 100 = top of viewport touches top of element
7
7
  * @param rootMargin - Custom rootMargin string (takes precedence over offset)
8
8
  * @returns rootMargin string for IntersectionObserver
9
9
  */
@@ -0,0 +1,20 @@
1
+ import type { AnimationType } from './animations';
2
+ /**
3
+ * Set CSS custom properties on an element
4
+ * @param element - Target DOM element
5
+ * @param duration - Animation duration in milliseconds
6
+ * @param delay - Animation delay in milliseconds
7
+ */
8
+ export declare function setCSSVariables(element: HTMLElement, duration?: number, delay?: number): void;
9
+ /**
10
+ * Setup animation element with required classes and attributes
11
+ * @param element - Target DOM element
12
+ * @param animation - Animation type to apply
13
+ */
14
+ export declare function setupAnimationElement(element: HTMLElement, animation: AnimationType): void;
15
+ /**
16
+ * Create and inject invisible sentinel element for observer-based triggering
17
+ * @param element - Reference element (sentinel will be placed after it)
18
+ * @returns The created sentinel element
19
+ */
20
+ export declare function createSentinel(element: HTMLElement): HTMLElement;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Set CSS custom properties on an element
3
+ * @param element - Target DOM element
4
+ * @param duration - Animation duration in milliseconds
5
+ * @param delay - Animation delay in milliseconds
6
+ */
7
+ export function setCSSVariables(element, duration, delay = 0) {
8
+ if (duration !== undefined) {
9
+ element.style.setProperty('--duration', `${duration}ms`);
10
+ }
11
+ element.style.setProperty('--delay', `${delay}ms`);
12
+ }
13
+ /**
14
+ * Setup animation element with required classes and attributes
15
+ * @param element - Target DOM element
16
+ * @param animation - Animation type to apply
17
+ */
18
+ export function setupAnimationElement(element, animation) {
19
+ element.classList.add('scroll-animate');
20
+ element.setAttribute('data-animation', animation);
21
+ }
22
+ /**
23
+ * Create and inject invisible sentinel element for observer-based triggering
24
+ * @param element - Reference element (sentinel will be placed after it)
25
+ * @returns The created sentinel element
26
+ */
27
+ export function createSentinel(element) {
28
+ const sentinel = document.createElement('div');
29
+ // Use cssText for efficient single-statement styling
30
+ sentinel.style.cssText = 'height:20px;margin-top:0.5rem;visibility:hidden';
31
+ element.parentNode?.insertBefore(sentinel, element.nextSibling);
32
+ return sentinel;
33
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { default as rs } from './Rs.svelte';
2
2
  export { animate } from './animate.svelte';
3
3
  export type { AnimateOptions } from './animate.svelte';
4
+ export { runeScroller } from './runeScroller.svelte';
5
+ export type { RuneScrollerOptions } from './runeScroller.svelte';
4
6
  export { useIntersection, useIntersectionOnce } from './useIntersection.svelte';
5
7
  export type { IntersectionOptions, UseIntersectionReturn } from './useIntersection.svelte';
6
8
  export { calculateRootMargin } from './animations';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export { default as rs } from './Rs.svelte';
3
3
  // Actions
4
4
  export { animate } from './animate.svelte';
5
+ export { runeScroller } from './runeScroller.svelte';
5
6
  // Composables
6
7
  export { useIntersection, useIntersectionOnce } from './useIntersection.svelte';
7
8
  // Utilities
@@ -0,0 +1,29 @@
1
+ import type { AnimationType } from './animations';
2
+ export interface RuneScrollerOptions {
3
+ animation?: AnimationType;
4
+ duration?: number;
5
+ repeat?: boolean;
6
+ }
7
+ /**
8
+ * Action pour animer un élément au scroll avec un sentinel invisible juste en dessous
9
+ * @param element - L'élément à animer
10
+ * @param options - Options d'animation (animation type, duration, et repeat)
11
+ * @returns Objet action Svelte
12
+ *
13
+ * @example
14
+ * ```svelte
15
+ * <!-- Animation une seule fois -->
16
+ * <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
17
+ * Content
18
+ * </div>
19
+ *
20
+ * <!-- Animation répétée à chaque scroll -->
21
+ * <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000, repeat: true }}>
22
+ * Content
23
+ * </div>
24
+ * ```
25
+ */
26
+ export declare function runeScroller(element: HTMLElement, options?: RuneScrollerOptions): {
27
+ update(newOptions?: RuneScrollerOptions): void;
28
+ destroy(): void;
29
+ };
@@ -0,0 +1,68 @@
1
+ import { setCSSVariables, setupAnimationElement, createSentinel } from './dom-utils.svelte';
2
+ /**
3
+ * Action pour animer un élément au scroll avec un sentinel invisible juste en dessous
4
+ * @param element - L'élément à animer
5
+ * @param options - Options d'animation (animation type, duration, et repeat)
6
+ * @returns Objet action Svelte
7
+ *
8
+ * @example
9
+ * ```svelte
10
+ * <!-- Animation une seule fois -->
11
+ * <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
12
+ * Content
13
+ * </div>
14
+ *
15
+ * <!-- Animation répétée à chaque scroll -->
16
+ * <div use:runeScroller={{ animation: 'fade-in-up', duration: 1000, repeat: true }}>
17
+ * Content
18
+ * </div>
19
+ * ```
20
+ */
21
+ export function runeScroller(element, options) {
22
+ // Setup animation classes et variables CSS
23
+ if (options?.animation || options?.duration) {
24
+ setupAnimationElement(element, options.animation);
25
+ setCSSVariables(element, options.duration);
26
+ }
27
+ // Créer le sentinel invisible juste en dessous
28
+ const sentinel = createSentinel(element);
29
+ // Observer le sentinel avec cleanup tracking
30
+ let observerConnected = true;
31
+ const observer = new IntersectionObserver((entries) => {
32
+ const isIntersecting = entries[0].isIntersecting;
33
+ if (isIntersecting) {
34
+ // Ajouter la classe is-visible à l'élément
35
+ element.classList.add('is-visible');
36
+ // Déconnecter si pas en mode repeat
37
+ if (!options?.repeat) {
38
+ observer.disconnect();
39
+ observerConnected = false;
40
+ }
41
+ }
42
+ else if (options?.repeat) {
43
+ // En mode repeat, retirer la classe quand le sentinel sort
44
+ element.classList.remove('is-visible');
45
+ }
46
+ }, { threshold: 0 });
47
+ observer.observe(sentinel);
48
+ return {
49
+ update(newOptions) {
50
+ if (newOptions?.animation) {
51
+ element.setAttribute('data-animation', newOptions.animation);
52
+ }
53
+ if (newOptions?.duration) {
54
+ setCSSVariables(element, newOptions.duration);
55
+ }
56
+ // Update repeat option
57
+ if (newOptions?.repeat !== undefined && newOptions.repeat !== options?.repeat) {
58
+ options = { ...options, repeat: newOptions.repeat };
59
+ }
60
+ },
61
+ destroy() {
62
+ if (observerConnected) {
63
+ observer.disconnect();
64
+ }
65
+ sentinel.remove();
66
+ }
67
+ };
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rune-scroller",
3
- "version": "0.0.2",
3
+ "version": "0.1.1",
4
4
  "description": "Lightweight, high-performance scroll animations for Svelte 5. ~2KB bundle, zero dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -56,7 +56,6 @@
56
56
  "devDependencies": {
57
57
  "@eslint/compat": "^1.4.0",
58
58
  "@eslint/js": "^9.36.0",
59
- "@sveltejs/adapter-auto": "^3.2.2",
60
59
  "@sveltejs/kit": "^2.43.2",
61
60
  "@sveltejs/package": "^2.5.4",
62
61
  "@sveltejs/vite-plugin-svelte": "^6.2.0",
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>