rune-scroller 0.0.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/LICENSE +21 -0
- package/README.md +576 -0
- package/dist/AnimatedElements.svelte +26 -0
- package/dist/AnimatedElements.svelte.d.ts +11 -0
- package/dist/BaseAnimated.svelte +53 -0
- package/dist/BaseAnimated.svelte.d.ts +16 -0
- package/dist/ScrollAnimate.svelte +30 -0
- package/dist/ScrollAnimate.svelte.d.ts +13 -0
- package/dist/animate.svelte.d.ts +22 -0
- package/dist/animate.svelte.js +63 -0
- package/dist/animations.css +170 -0
- package/dist/animations.d.ts +33 -0
- package/dist/animations.js +101 -0
- package/dist/assets/favicon.svg +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/useIntersection.svelte.d.ts +32 -0
- package/dist/useIntersection.svelte.js +71 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 ludoloops
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# ⚡ Rune Scroller
|
|
2
|
+
|
|
3
|
+
**Native Scroll Animations for Svelte 5** — Built with **Svelte 5 Runes** and **IntersectionObserver API**. No external dependencies, pure performance.
|
|
4
|
+
|
|
5
|
+
> 🚀 **Open Source Project** by **[ludoloops](https://github.com/ludoloops)** at **[LeLab.dev](https://lelab.dev)**
|
|
6
|
+
> 📜 Licensed under **MIT** — Contributions welcome!
|
|
7
|
+
>
|
|
8
|
+
> A modern, lightweight scroll animation library showcasing Svelte 5 capabilities
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## ✨ Features
|
|
13
|
+
|
|
14
|
+
- ✅ **~2KB Bundle** : Only **1.9 KB gzipped** (52% lighter than AOS!)
|
|
15
|
+
- ✅ **Svelte 5 Runes** : `$state`, `$props()` with snippets
|
|
16
|
+
- ✅ **Zero Dependencies** : Pure Svelte 5 + IntersectionObserver
|
|
17
|
+
- ✅ **Native Performance** : GPU-accelerated CSS animations
|
|
18
|
+
- ✅ **26+ Animations** : Fade, Zoom, Flip, Slide, Bounce, and more
|
|
19
|
+
- ✅ **TypeScript** : Full type coverage with strict mode
|
|
20
|
+
- ✅ **Customizable** : Duration, delay, threshold, offset per element
|
|
21
|
+
- ✅ **Play Once or Repeat** : Control animation behavior
|
|
22
|
+
- ✅ **SSR-ready** : SvelteKit compatible with no DOM access during hydration
|
|
23
|
+
- ✅ **Accessible** : Respects `prefers-reduced-motion` media query
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ⚡ Performance: Svelte Projects Bundle Comparison
|
|
28
|
+
|
|
29
|
+
### When Using in a Svelte Project
|
|
30
|
+
|
|
31
|
+
| Scenario | Bundle Size | Impact |
|
|
32
|
+
| ------------------------- | ----------------- | -------------------- |
|
|
33
|
+
| **Svelte App (baseline)** | ~30-35 KB gzipped | - |
|
|
34
|
+
| **+ AOS Library** | ~34-39 KB | **+4 KB overhead** |
|
|
35
|
+
| **+ Rune Scroller** | ~31.9-36.9 KB | **+1.9 KB overhead** |
|
|
36
|
+
| **Savings** | **2.1 KB** | **52% smaller** ✨ |
|
|
37
|
+
|
|
38
|
+
### Why Rune Scroller is Lighter
|
|
39
|
+
|
|
40
|
+
1. **Native Svelte Integration** - Uses `$state()` directly (no separate state lib)
|
|
41
|
+
2. **CSS-Based Animations** - Pure CSS transforms + GPU acceleration (no JS animation loop)
|
|
42
|
+
3. **Svelte 5 Optimized** - Leverages runes system for minimal overhead
|
|
43
|
+
4. **Zero External Dependencies** - Works with Svelte's native IntersectionObserver
|
|
44
|
+
|
|
45
|
+
### Real-World Impact
|
|
46
|
+
|
|
47
|
+
For a typical SvelteKit app:
|
|
48
|
+
|
|
49
|
+
- **With AOS**: Extra 4 KB per user download
|
|
50
|
+
- **With Rune Scroller**: Extra 1.9 KB per user download
|
|
51
|
+
- **Difference**: Save **2.1 KB per page load** = faster initial paint! 🚀
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 📦 Project Structure
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
rune-scroller/
|
|
59
|
+
├── src/lib/
|
|
60
|
+
│ ├── ScrollAnimate.svelte # One-time animation component
|
|
61
|
+
│ ├── AnimatedElements.svelte # Repeating animation component
|
|
62
|
+
│ ├── useIntersection.svelte.ts # IntersectionObserver composable
|
|
63
|
+
│ ├── animations.ts # Animation configuration
|
|
64
|
+
│ ├── animations.css # Animation styles
|
|
65
|
+
│ └── viking-theme.css # Modern granite theme
|
|
66
|
+
├── src/routes/
|
|
67
|
+
│ ├── +layout.svelte
|
|
68
|
+
│ └── +page.svelte # Demo page
|
|
69
|
+
└── package.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 🚀 Quick Start
|
|
75
|
+
|
|
76
|
+
### 1. ScrollAnimate Component (Plays Once)
|
|
77
|
+
|
|
78
|
+
Use `ScrollAnimate` for animations that play once when elements enter the viewport:
|
|
79
|
+
|
|
80
|
+
```svelte
|
|
81
|
+
<script>
|
|
82
|
+
import ScrollAnimate from '$lib/ScrollAnimate.svelte';
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<ScrollAnimate animation="fade-in">
|
|
86
|
+
<div class="rune-card">
|
|
87
|
+
<h2>Hello World</h2>
|
|
88
|
+
<p>This element fades in once</p>
|
|
89
|
+
</div>
|
|
90
|
+
</ScrollAnimate>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. AnimatedElements Component (Repeating)
|
|
94
|
+
|
|
95
|
+
Use `AnimatedElements` for animations that trigger every time the element enters the viewport:
|
|
96
|
+
|
|
97
|
+
```svelte
|
|
98
|
+
<script>
|
|
99
|
+
import AnimatedElements from '$lib/AnimatedElements.svelte';
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<AnimatedElements animation="zoom-in">
|
|
103
|
+
<div class="rune-card">
|
|
104
|
+
<h2>Repeating Animation</h2>
|
|
105
|
+
<p>This triggers each time you scroll past it</p>
|
|
106
|
+
</div>
|
|
107
|
+
</AnimatedElements>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## ⚙️ Component Props
|
|
113
|
+
|
|
114
|
+
### ScrollAnimate
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
interface ScrollAnimateProps {
|
|
118
|
+
animation?: string; // Animation type (default: 'fade-in')
|
|
119
|
+
threshold?: number; // Visibility threshold (default: 0.5)
|
|
120
|
+
offset?: number; // Trigger offset 0-100% (optional, uses default if not set)
|
|
121
|
+
rootMargin?: string; // Observer margin (overrides offset if set)
|
|
122
|
+
duration?: number; // Duration in ms (default: 800)
|
|
123
|
+
delay?: number; // Delay in ms (default: 0)
|
|
124
|
+
children: Snippet; // Content to animate
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### `offset` Prop (Optional)
|
|
129
|
+
|
|
130
|
+
Controls when the animation triggers as the element scrolls into view. If not specified, uses default behavior (`-10% 0px -10% 0px`).
|
|
131
|
+
|
|
132
|
+
- `offset={0}` — Triggers when element touches bottom of screen
|
|
133
|
+
- `offset={50}` — Triggers at middle of screen
|
|
134
|
+
- `offset={100}` — Triggers when element reaches top of screen
|
|
135
|
+
- **Not set** — Uses default behavior (triggers in middle ~80% band)
|
|
136
|
+
|
|
137
|
+
**Examples:**
|
|
138
|
+
|
|
139
|
+
```svelte
|
|
140
|
+
<!-- Early trigger (bottom of screen) -->
|
|
141
|
+
<ScrollAnimate animation="fade-in-up" offset={0}>
|
|
142
|
+
<div class="rune-card">Animates early</div>
|
|
143
|
+
</ScrollAnimate>
|
|
144
|
+
|
|
145
|
+
<!-- Late trigger (top of screen) -->
|
|
146
|
+
<ScrollAnimate animation="fade-in-up" offset={100}>
|
|
147
|
+
<div class="rune-card">Animates late</div>
|
|
148
|
+
</ScrollAnimate>
|
|
149
|
+
|
|
150
|
+
<!-- Custom timing -->
|
|
151
|
+
<ScrollAnimate animation="fade-in-up" offset={75}>
|
|
152
|
+
<div class="rune-card">Animates at 75%</div>
|
|
153
|
+
</ScrollAnimate>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Full example with all props:**
|
|
157
|
+
|
|
158
|
+
```svelte
|
|
159
|
+
<ScrollAnimate animation="fade-in-up" duration={1200} delay={300} threshold={0.8} offset={25}>
|
|
160
|
+
<div class="rune-card">
|
|
161
|
+
<h2>Custom Timing</h2>
|
|
162
|
+
<p>Duration: 1200ms, Delay: 300ms, Threshold: 80%, Offset: 25%</p>
|
|
163
|
+
</div>
|
|
164
|
+
</ScrollAnimate>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### AnimatedElements
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
interface AnimatedElementsProps {
|
|
171
|
+
animation?: string; // Animation type (default: 'fade-in')
|
|
172
|
+
threshold?: number; // Visibility threshold (default: 0.5)
|
|
173
|
+
offset?: number; // Trigger offset 0-100% (optional, uses default if not set)
|
|
174
|
+
rootMargin?: string; // Observer margin (overrides offset if set)
|
|
175
|
+
children: Snippet; // Content to animate
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Same `offset` behavior as `ScrollAnimate`, but animation **repeats on every scroll pass** instead of playing once.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 🎨 All Animations with Examples
|
|
184
|
+
|
|
185
|
+
### Fade (5 variants)
|
|
186
|
+
|
|
187
|
+
#### `fade-in`
|
|
188
|
+
|
|
189
|
+
Simple opacity fade from transparent to visible.
|
|
190
|
+
|
|
191
|
+
```svelte
|
|
192
|
+
<ScrollAnimate animation="fade-in">
|
|
193
|
+
<div class="rune-card">
|
|
194
|
+
<h2>Fade In</h2>
|
|
195
|
+
<p>Simple fade entrance</p>
|
|
196
|
+
</div>
|
|
197
|
+
</ScrollAnimate>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### `fade-in-up`
|
|
201
|
+
|
|
202
|
+
Fades in while moving up 100px.
|
|
203
|
+
|
|
204
|
+
```svelte
|
|
205
|
+
<ScrollAnimate animation="fade-in-up">
|
|
206
|
+
<div class="rune-card">
|
|
207
|
+
<h2>Fade In Up</h2>
|
|
208
|
+
<p>Rises from below</p>
|
|
209
|
+
</div>
|
|
210
|
+
</ScrollAnimate>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
#### `fade-in-down`
|
|
214
|
+
|
|
215
|
+
Fades in while moving down 100px.
|
|
216
|
+
|
|
217
|
+
```svelte
|
|
218
|
+
<ScrollAnimate animation="fade-in-down">
|
|
219
|
+
<div class="rune-card">
|
|
220
|
+
<h2>Fade In Down</h2>
|
|
221
|
+
<p>Descends from above</p>
|
|
222
|
+
</div>
|
|
223
|
+
</ScrollAnimate>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### `fade-in-left`
|
|
227
|
+
|
|
228
|
+
Fades in while moving left 100px.
|
|
229
|
+
|
|
230
|
+
```svelte
|
|
231
|
+
<ScrollAnimate animation="fade-in-left">
|
|
232
|
+
<div class="rune-card">
|
|
233
|
+
<h2>Fade In Left</h2>
|
|
234
|
+
<p>Comes from the right</p>
|
|
235
|
+
</div>
|
|
236
|
+
</ScrollAnimate>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### `fade-in-right`
|
|
240
|
+
|
|
241
|
+
Fades in while moving right 100px.
|
|
242
|
+
|
|
243
|
+
```svelte
|
|
244
|
+
<ScrollAnimate animation="fade-in-right">
|
|
245
|
+
<div class="rune-card">
|
|
246
|
+
<h2>Fade In Right</h2>
|
|
247
|
+
<p>Comes from the left</p>
|
|
248
|
+
</div>
|
|
249
|
+
</ScrollAnimate>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
### Zoom (2 variants)
|
|
255
|
+
|
|
256
|
+
#### `zoom-in`
|
|
257
|
+
|
|
258
|
+
Scales from 50% to 100% while fading in.
|
|
259
|
+
|
|
260
|
+
```svelte
|
|
261
|
+
<ScrollAnimate animation="zoom-in">
|
|
262
|
+
<div class="rune-card">
|
|
263
|
+
<h2>Zoom In</h2>
|
|
264
|
+
<p>Grows into view</p>
|
|
265
|
+
</div>
|
|
266
|
+
</ScrollAnimate>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
#### `zoom-out`
|
|
270
|
+
|
|
271
|
+
Scales from 150% to 100% while fading in.
|
|
272
|
+
|
|
273
|
+
```svelte
|
|
274
|
+
<ScrollAnimate animation="zoom-out">
|
|
275
|
+
<div class="rune-card">
|
|
276
|
+
<h2>Zoom Out</h2>
|
|
277
|
+
<p>Shrinks into view</p>
|
|
278
|
+
</div>
|
|
279
|
+
</ScrollAnimate>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
### Flip (2 variants)
|
|
285
|
+
|
|
286
|
+
#### `flip`
|
|
287
|
+
|
|
288
|
+
3D rotation on Y axis (left to right).
|
|
289
|
+
|
|
290
|
+
```svelte
|
|
291
|
+
<ScrollAnimate animation="flip">
|
|
292
|
+
<div class="rune-card">
|
|
293
|
+
<h2>Flip</h2>
|
|
294
|
+
<p>Rotates on Y axis</p>
|
|
295
|
+
</div>
|
|
296
|
+
</ScrollAnimate>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
#### `flip-x`
|
|
300
|
+
|
|
301
|
+
3D rotation on X axis (top to bottom).
|
|
302
|
+
|
|
303
|
+
```svelte
|
|
304
|
+
<ScrollAnimate animation="flip-x">
|
|
305
|
+
<div class="rune-card">
|
|
306
|
+
<h2>Flip X</h2>
|
|
307
|
+
<p>Rotates on X axis</p>
|
|
308
|
+
</div>
|
|
309
|
+
</ScrollAnimate>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### Slide & Rotate
|
|
315
|
+
|
|
316
|
+
#### `slide-rotate`
|
|
317
|
+
|
|
318
|
+
Slides from left while rotating 45 degrees.
|
|
319
|
+
|
|
320
|
+
```svelte
|
|
321
|
+
<ScrollAnimate animation="slide-rotate">
|
|
322
|
+
<div class="rune-card">
|
|
323
|
+
<h2>Slide Rotate</h2>
|
|
324
|
+
<p>Slides and spins</p>
|
|
325
|
+
</div>
|
|
326
|
+
</ScrollAnimate>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
### Bounce
|
|
332
|
+
|
|
333
|
+
#### `bounce-in`
|
|
334
|
+
|
|
335
|
+
Bouncy entrance with scaling keyframe animation.
|
|
336
|
+
|
|
337
|
+
```svelte
|
|
338
|
+
<ScrollAnimate animation="bounce-in" duration={800}>
|
|
339
|
+
<div class="rune-card">
|
|
340
|
+
<h2>Bounce In</h2>
|
|
341
|
+
<p>Bounces into view</p>
|
|
342
|
+
</div>
|
|
343
|
+
</ScrollAnimate>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
### Compare: Once vs Repeat
|
|
349
|
+
|
|
350
|
+
**Same animation, different behavior:**
|
|
351
|
+
|
|
352
|
+
```svelte
|
|
353
|
+
<!-- Plays once on scroll down -->
|
|
354
|
+
<ScrollAnimate animation="fade-in-up">
|
|
355
|
+
<div class="rune-card">Animates once</div>
|
|
356
|
+
</ScrollAnimate>
|
|
357
|
+
|
|
358
|
+
<!-- Repeats each time you scroll by -->
|
|
359
|
+
<AnimatedElements animation="fade-in-up">
|
|
360
|
+
<div class="rune-card">Animates on every scroll</div>
|
|
361
|
+
</AnimatedElements>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## 💡 Usage Examples
|
|
367
|
+
|
|
368
|
+
### Staggered Grid
|
|
369
|
+
|
|
370
|
+
Animate cards with progressive delays:
|
|
371
|
+
|
|
372
|
+
```svelte
|
|
373
|
+
<script>
|
|
374
|
+
import ScrollAnimate from '$lib/ScrollAnimate.svelte';
|
|
375
|
+
</script>
|
|
376
|
+
|
|
377
|
+
<div class="grid">
|
|
378
|
+
{#each items as item, i}
|
|
379
|
+
<ScrollAnimate animation="fade-in-up" delay={i * 100}>
|
|
380
|
+
<div class="rune-card">
|
|
381
|
+
<h3>{item.title}</h3>
|
|
382
|
+
<p>{item.description}</p>
|
|
383
|
+
</div>
|
|
384
|
+
</ScrollAnimate>
|
|
385
|
+
{/each}
|
|
386
|
+
</div>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Mixed Animations
|
|
390
|
+
|
|
391
|
+
```svelte
|
|
392
|
+
<ScrollAnimate animation="fade-in">
|
|
393
|
+
<section>Content fades in</section>
|
|
394
|
+
</ScrollAnimate>
|
|
395
|
+
|
|
396
|
+
<ScrollAnimate animation="slide-rotate">
|
|
397
|
+
<section>Content slides and rotates</section>
|
|
398
|
+
</ScrollAnimate>
|
|
399
|
+
|
|
400
|
+
<AnimatedElements animation="zoom-in">
|
|
401
|
+
<section>Content zooms in repeatedly</section>
|
|
402
|
+
</AnimatedElements>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Hero Section
|
|
406
|
+
|
|
407
|
+
```svelte
|
|
408
|
+
<script>
|
|
409
|
+
import ScrollAnimate from '$lib/ScrollAnimate.svelte';
|
|
410
|
+
</script>
|
|
411
|
+
|
|
412
|
+
<section class="hero">
|
|
413
|
+
<div class="hero-content">
|
|
414
|
+
<ScrollAnimate animation="fade-in" delay={0}>
|
|
415
|
+
<h1>Welcome</h1>
|
|
416
|
+
</ScrollAnimate>
|
|
417
|
+
|
|
418
|
+
<ScrollAnimate animation="fade-in" delay={200}>
|
|
419
|
+
<p>Scroll to reveal more</p>
|
|
420
|
+
</ScrollAnimate>
|
|
421
|
+
|
|
422
|
+
<ScrollAnimate animation="zoom-in" delay={400}>
|
|
423
|
+
<button>Get Started</button>
|
|
424
|
+
</ScrollAnimate>
|
|
425
|
+
</div>
|
|
426
|
+
</section>
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## 🎨 Theming
|
|
432
|
+
|
|
433
|
+
The project includes a modern **Granite + Electric Blue** theme in `src/lib/viking-theme.css`.
|
|
434
|
+
|
|
435
|
+
### Color Palette
|
|
436
|
+
|
|
437
|
+
```css
|
|
438
|
+
--granite-dark: #0f1419;
|
|
439
|
+
--granite-medium: #1a1f2e;
|
|
440
|
+
--granite-light: #252d3d;
|
|
441
|
+
--electric-blue: #00d9ff;
|
|
442
|
+
--text-primary: #f0f2f5;
|
|
443
|
+
--text-secondary: #a8b0be;
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Card Classes
|
|
447
|
+
|
|
448
|
+
```svelte
|
|
449
|
+
<!-- Large card -->
|
|
450
|
+
<div class="rune-card">
|
|
451
|
+
<h2>Title</h2>
|
|
452
|
+
<p>Content</p>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<!-- Small card (for grids) -->
|
|
456
|
+
<div class="rune-card small">
|
|
457
|
+
<h3>Small Title</h3>
|
|
458
|
+
<p>Small content</p>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<!-- Divider line -->
|
|
462
|
+
<div class="rune-divider"></div>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## 🔧 Composables
|
|
468
|
+
|
|
469
|
+
### useIntersectionOnce
|
|
470
|
+
|
|
471
|
+
For animations that play only once (used by `ScrollAnimate`):
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
function useIntersectionOnce(options?: {
|
|
475
|
+
threshold?: number;
|
|
476
|
+
rootMargin?: string;
|
|
477
|
+
root?: Element | null;
|
|
478
|
+
});
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Returns `{ element, isVisible }` — bind `element` to your target, `isVisible` becomes `true` once.
|
|
482
|
+
|
|
483
|
+
### useIntersection
|
|
484
|
+
|
|
485
|
+
For repeating animations (used by `AnimatedElements`):
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
function useIntersection(
|
|
489
|
+
options?: {
|
|
490
|
+
threshold?: number;
|
|
491
|
+
rootMargin?: string;
|
|
492
|
+
root?: Element | null;
|
|
493
|
+
},
|
|
494
|
+
onVisible?: (isVisible: boolean) => void
|
|
495
|
+
);
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Returns `{ element, isVisible }` — `isVisible` toggles on each scroll.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## 🏗️ Architecture
|
|
503
|
+
|
|
504
|
+
### Animation System
|
|
505
|
+
|
|
506
|
+
1. **animations.ts** - Configuration and validation
|
|
507
|
+
2. **animations.css** - Reusable animation styles
|
|
508
|
+
3. **useIntersection.svelte.ts** - IntersectionObserver logic
|
|
509
|
+
4. **ScrollAnimate.svelte** - One-time wrapper component
|
|
510
|
+
5. **AnimatedElements.svelte** - Repeating wrapper component
|
|
511
|
+
|
|
512
|
+
### Key Principles
|
|
513
|
+
|
|
514
|
+
- **Separation of Concerns** : Scroll logic separate from components
|
|
515
|
+
- **CSS-Based** : Animations use CSS transforms + transitions (hardware-accelerated)
|
|
516
|
+
- **Type-Safe** : Full TypeScript support
|
|
517
|
+
- **Composable** : Use hooks directly or wrapped components
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## 📊 Performance
|
|
522
|
+
|
|
523
|
+
- **IntersectionObserver** : Native browser API, no scroll listeners
|
|
524
|
+
- **CSS Transforms** : Hardware-accelerated (GPU)
|
|
525
|
+
- **Lazy Loading** : Only animate visible elements
|
|
526
|
+
- **Memory Efficient** : Automatic cleanup on unmount
|
|
527
|
+
- **SSR Compatible** : No DOM access during hydration
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## 🎯 Development
|
|
532
|
+
|
|
533
|
+
```bash
|
|
534
|
+
# Install dependencies
|
|
535
|
+
pnpm install
|
|
536
|
+
|
|
537
|
+
# Dev server
|
|
538
|
+
pnpm dev
|
|
539
|
+
|
|
540
|
+
# Type checking
|
|
541
|
+
pnpm check
|
|
542
|
+
|
|
543
|
+
# Format code
|
|
544
|
+
pnpm format
|
|
545
|
+
|
|
546
|
+
# Preview build
|
|
547
|
+
pnpm preview
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## 📝 Notes
|
|
553
|
+
|
|
554
|
+
- **Why "Rune"?** Svelte 5 uses **Runes** (`$state`, `$props()`) as core reactivity primitives
|
|
555
|
+
- **Theme Name** : Granite + Electric Blue = Modern, minimalist aesthetic
|
|
556
|
+
- **No Dependencies** : Pure Svelte 5 + Browser APIs
|
|
557
|
+
- **Extensible** : Add new animations by extending `animations.ts` and `animations.css`
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## 🔗 Links
|
|
562
|
+
|
|
563
|
+
- [Svelte 5 Documentation](https://svelte.dev)
|
|
564
|
+
- [IntersectionObserver MDN](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
|
|
565
|
+
- [LeLab.dev](https://lelab.dev)
|
|
566
|
+
- [GitHub Repository](https://github.com/ludoloops/rune-scroller)
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## 📄 License & Credits
|
|
571
|
+
|
|
572
|
+
**MIT License** — Free for personal and commercial use.
|
|
573
|
+
|
|
574
|
+
Made with ⚡ by **[ludoloops](https://github.com/ludoloops)** at **[LeLab.dev](https://lelab.dev)**
|
|
575
|
+
|
|
576
|
+
**Open Source Project** — Contributions, issues, and feature requests are welcome!
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import BaseAnimated from './BaseAnimated.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
animation?: string;
|
|
7
|
+
threshold?: number;
|
|
8
|
+
rootMargin?: string;
|
|
9
|
+
offset?: number;
|
|
10
|
+
children: Snippet;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
animation = 'fade-in',
|
|
15
|
+
threshold = 0.5,
|
|
16
|
+
rootMargin,
|
|
17
|
+
offset,
|
|
18
|
+
children
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<!--
|
|
23
|
+
* Wrapper component for repeating scroll animations
|
|
24
|
+
* Triggers animation each time element enters viewport
|
|
25
|
+
-->
|
|
26
|
+
<BaseAnimated {animation} {threshold} {rootMargin} {offset} once={false} {children} />
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
animation?: string;
|
|
4
|
+
threshold?: number;
|
|
5
|
+
rootMargin?: string;
|
|
6
|
+
offset?: number;
|
|
7
|
+
children: Snippet;
|
|
8
|
+
}
|
|
9
|
+
declare const AnimatedElements: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type AnimatedElements = ReturnType<typeof AnimatedElements>;
|
|
11
|
+
export default AnimatedElements;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { useIntersection, useIntersectionOnce } from './useIntersection.svelte';
|
|
4
|
+
import { isValidAnimation, calculateRootMargin, type AnimationType } from './animations';
|
|
5
|
+
import './animations.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
animation?: AnimationType;
|
|
9
|
+
threshold?: number;
|
|
10
|
+
rootMargin?: string;
|
|
11
|
+
offset?: number;
|
|
12
|
+
duration?: number;
|
|
13
|
+
delay?: number;
|
|
14
|
+
once?: boolean;
|
|
15
|
+
children: Snippet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
animation = 'fade-in',
|
|
20
|
+
threshold = 0.5,
|
|
21
|
+
rootMargin,
|
|
22
|
+
offset,
|
|
23
|
+
duration = 800,
|
|
24
|
+
delay = 0,
|
|
25
|
+
once = false,
|
|
26
|
+
children
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
// Validate animation
|
|
30
|
+
if (!isValidAnimation(animation)) {
|
|
31
|
+
console.warn(`Invalid animation: ${animation}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Calculate rootMargin from offset if provided
|
|
35
|
+
const finalRootMargin = calculateRootMargin(offset, rootMargin);
|
|
36
|
+
|
|
37
|
+
// Use appropriate composable based on once prop
|
|
38
|
+
const intersection = once
|
|
39
|
+
? useIntersectionOnce({ threshold, rootMargin: finalRootMargin })
|
|
40
|
+
: useIntersection({ threshold, rootMargin: finalRootMargin });
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<div
|
|
44
|
+
bind:this={intersection.element}
|
|
45
|
+
class="scroll-animate"
|
|
46
|
+
class:is-visible={intersection.isVisible}
|
|
47
|
+
data-animation={animation}
|
|
48
|
+
style="--duration: {duration}ms; --delay: {delay}ms;"
|
|
49
|
+
>
|
|
50
|
+
{@render children()}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- Styles are imported from animations.css -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type AnimationType } from './animations';
|
|
3
|
+
import './animations.css';
|
|
4
|
+
interface Props {
|
|
5
|
+
animation?: AnimationType;
|
|
6
|
+
threshold?: number;
|
|
7
|
+
rootMargin?: string;
|
|
8
|
+
offset?: number;
|
|
9
|
+
duration?: number;
|
|
10
|
+
delay?: number;
|
|
11
|
+
once?: boolean;
|
|
12
|
+
children: Snippet;
|
|
13
|
+
}
|
|
14
|
+
declare const BaseAnimated: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type BaseAnimated = ReturnType<typeof BaseAnimated>;
|
|
16
|
+
export default BaseAnimated;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import BaseAnimated from './BaseAnimated.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
animation?: string;
|
|
7
|
+
threshold?: number;
|
|
8
|
+
rootMargin?: string;
|
|
9
|
+
offset?: number;
|
|
10
|
+
duration?: number;
|
|
11
|
+
delay?: number;
|
|
12
|
+
children: Snippet;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
animation = 'fade-in',
|
|
17
|
+
threshold = 0.5,
|
|
18
|
+
rootMargin,
|
|
19
|
+
offset,
|
|
20
|
+
duration = 800,
|
|
21
|
+
delay = 0,
|
|
22
|
+
children
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<!--
|
|
27
|
+
* Wrapper component for one-time scroll animations
|
|
28
|
+
* Triggers animation once when element enters viewport, then unobserves
|
|
29
|
+
-->
|
|
30
|
+
<BaseAnimated {animation} {threshold} {rootMargin} {offset} {duration} {delay} once={true} {children} />
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
animation?: string;
|
|
4
|
+
threshold?: number;
|
|
5
|
+
rootMargin?: string;
|
|
6
|
+
offset?: number;
|
|
7
|
+
duration?: number;
|
|
8
|
+
delay?: number;
|
|
9
|
+
children: Snippet;
|
|
10
|
+
}
|
|
11
|
+
declare const ScrollAnimate: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type ScrollAnimate = ReturnType<typeof ScrollAnimate>;
|
|
13
|
+
export default ScrollAnimate;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
import { type AnimationType } from './animations';
|
|
3
|
+
export interface AnimateOptions {
|
|
4
|
+
animation?: AnimationType;
|
|
5
|
+
duration?: number;
|
|
6
|
+
delay?: number;
|
|
7
|
+
offset?: number;
|
|
8
|
+
threshold?: number;
|
|
9
|
+
rootMargin?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Svelte action for scroll animations
|
|
13
|
+
* Triggers animation once when element enters viewport
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```svelte
|
|
17
|
+
* <div use:animate={{ animation: 'fade-up', duration: 1000 }}>
|
|
18
|
+
* Content
|
|
19
|
+
* </div>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare const animate: Action<HTMLElement, AnimateOptions>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isValidAnimation, calculateRootMargin } from './animations';
|
|
2
|
+
/**
|
|
3
|
+
* Svelte action for scroll animations
|
|
4
|
+
* Triggers animation once when element enters viewport
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <div use:animate={{ animation: 'fade-up', duration: 1000 }}>
|
|
9
|
+
* Content
|
|
10
|
+
* </div>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export const animate = (node, options = {}) => {
|
|
14
|
+
const { animation = 'fade-in', duration = 800, delay = 0, offset, threshold = 0, rootMargin } = options;
|
|
15
|
+
// Validate animation type
|
|
16
|
+
if (!isValidAnimation(animation)) {
|
|
17
|
+
console.warn(`[Rune Scroller] Invalid animation: "${animation}"`);
|
|
18
|
+
}
|
|
19
|
+
// Calculate rootMargin from offset (0-100%)
|
|
20
|
+
const finalRootMargin = calculateRootMargin(offset, rootMargin);
|
|
21
|
+
// Set CSS custom properties for timing
|
|
22
|
+
node.style.setProperty('--duration', `${duration}ms`);
|
|
23
|
+
node.style.setProperty('--delay', `${delay}ms`);
|
|
24
|
+
// Add base animation class and data attribute
|
|
25
|
+
node.classList.add('scroll-animate');
|
|
26
|
+
node.setAttribute('data-animation', animation);
|
|
27
|
+
// Track if animation has been triggered
|
|
28
|
+
let animated = false;
|
|
29
|
+
// Create IntersectionObserver for one-time animation
|
|
30
|
+
const observer = new IntersectionObserver((entries) => {
|
|
31
|
+
entries.forEach((entry) => {
|
|
32
|
+
// Trigger animation once when element enters viewport
|
|
33
|
+
if (entry.isIntersecting && !animated) {
|
|
34
|
+
node.classList.add('is-visible');
|
|
35
|
+
animated = true;
|
|
36
|
+
// Stop observing after animation triggers
|
|
37
|
+
observer.unobserve(node);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}, {
|
|
41
|
+
threshold,
|
|
42
|
+
rootMargin: finalRootMargin
|
|
43
|
+
});
|
|
44
|
+
observer.observe(node);
|
|
45
|
+
return {
|
|
46
|
+
update(newOptions) {
|
|
47
|
+
const { duration: newDuration = 800, delay: newDelay = 0, animation: newAnimation } = newOptions;
|
|
48
|
+
// Update CSS properties
|
|
49
|
+
if (newDuration !== duration) {
|
|
50
|
+
node.style.setProperty('--duration', `${newDuration}ms`);
|
|
51
|
+
}
|
|
52
|
+
if (newDelay !== delay) {
|
|
53
|
+
node.style.setProperty('--delay', `${newDelay}ms`);
|
|
54
|
+
}
|
|
55
|
+
if (newAnimation && newAnimation !== animation) {
|
|
56
|
+
node.setAttribute('data-animation', newAnimation);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
destroy() {
|
|
60
|
+
observer.disconnect();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable scroll animation styles
|
|
3
|
+
* These styles define animation effects for scroll-triggered elements
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* Base animation container */
|
|
7
|
+
.scroll-animate,
|
|
8
|
+
.animated-element {
|
|
9
|
+
opacity: 0;
|
|
10
|
+
transition: all var(--duration, 800ms) cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
11
|
+
transition-delay: var(--delay, 0ms);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.scroll-animate.is-visible,
|
|
15
|
+
.animated-element.is-visible {
|
|
16
|
+
opacity: 1;
|
|
17
|
+
/* GPU acceleration only for visible elements to reduce initial memory pressure */
|
|
18
|
+
will-change: transform, opacity;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Animation states - transform-specific initial states */
|
|
22
|
+
/* (opacity: 0 is already set in .scroll-animate base class) */
|
|
23
|
+
|
|
24
|
+
/* Fade In Up */
|
|
25
|
+
[data-animation='fade-in-up'] {
|
|
26
|
+
transform: translateY(300px);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[data-animation='fade-in-up'].is-visible {
|
|
30
|
+
transform: translateY(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Fade In Down */
|
|
34
|
+
[data-animation='fade-in-down'] {
|
|
35
|
+
transform: translateY(-300px);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[data-animation='fade-in-down'].is-visible {
|
|
39
|
+
transform: translateY(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Fade In Left */
|
|
43
|
+
[data-animation='fade-in-left'] {
|
|
44
|
+
transform: translateX(-300px);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
[data-animation='fade-in-left'].is-visible {
|
|
48
|
+
transform: translateX(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Fade In Right */
|
|
52
|
+
[data-animation='fade-in-right'] {
|
|
53
|
+
transform: translateX(300px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
[data-animation='fade-in-right'].is-visible {
|
|
57
|
+
transform: translateX(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Zoom In */
|
|
61
|
+
[data-animation='zoom-in'] {
|
|
62
|
+
transform: scale(0.3);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
[data-animation='zoom-in'].is-visible {
|
|
66
|
+
transform: scale(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Zoom Out */
|
|
70
|
+
[data-animation='zoom-out'] {
|
|
71
|
+
transform: scale(2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
[data-animation='zoom-out'].is-visible {
|
|
75
|
+
transform: scale(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Zoom In Up */
|
|
79
|
+
[data-animation='zoom-in-up'] {
|
|
80
|
+
transform: scale(0.5) translateY(300px);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
[data-animation='zoom-in-up'].is-visible {
|
|
84
|
+
transform: scale(1) translateY(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Zoom In Left */
|
|
88
|
+
[data-animation='zoom-in-left'] {
|
|
89
|
+
transform: scale(0.5) translateX(-300px);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[data-animation='zoom-in-left'].is-visible {
|
|
93
|
+
transform: scale(1) translateX(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Zoom In Right */
|
|
97
|
+
[data-animation='zoom-in-right'] {
|
|
98
|
+
transform: scale(0.5) translateX(300px);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
[data-animation='zoom-in-right'].is-visible {
|
|
102
|
+
transform: scale(1) translateX(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Flip */
|
|
106
|
+
[data-animation='flip'] {
|
|
107
|
+
transform: perspective(1000px) rotateY(90deg);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
[data-animation='flip'].is-visible {
|
|
111
|
+
transform: perspective(1000px) rotateY(0deg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Flip X */
|
|
115
|
+
[data-animation='flip-x'] {
|
|
116
|
+
transform: perspective(1000px) rotateX(90deg);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
[data-animation='flip-x'].is-visible {
|
|
120
|
+
transform: perspective(1000px) rotateX(0deg);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Slide Rotate */
|
|
124
|
+
[data-animation='slide-rotate'] {
|
|
125
|
+
transform: translateX(-300px) rotate(-45deg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
[data-animation='slide-rotate'].is-visible {
|
|
129
|
+
transform: translateX(0) rotate(0deg);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Bounce In */
|
|
133
|
+
[data-animation='bounce-in'] {
|
|
134
|
+
transform: scale(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[data-animation='bounce-in'].is-visible {
|
|
138
|
+
transform: scale(1);
|
|
139
|
+
animation: bounce var(--duration, 800ms) cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
140
|
+
animation-delay: var(--delay, 0ms);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes bounce {
|
|
144
|
+
0% {
|
|
145
|
+
transform: scale(0);
|
|
146
|
+
}
|
|
147
|
+
50% {
|
|
148
|
+
transform: scale(1.1);
|
|
149
|
+
}
|
|
150
|
+
100% {
|
|
151
|
+
transform: scale(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Accessibility: Respect user's motion preferences */
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
.scroll-animate,
|
|
158
|
+
.animated-element {
|
|
159
|
+
/* Disable animations for users who prefer reduced motion */
|
|
160
|
+
transition: none;
|
|
161
|
+
animation: none !important;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Still show final state without animation */
|
|
165
|
+
.scroll-animate.is-visible,
|
|
166
|
+
.animated-element.is-visible {
|
|
167
|
+
opacity: 1;
|
|
168
|
+
transform: none !important;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation configuration and utilities
|
|
3
|
+
* Centralizes animation definitions to reduce duplication
|
|
4
|
+
*/
|
|
5
|
+
export interface AnimationConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
initial: {
|
|
8
|
+
opacity: number;
|
|
9
|
+
transform?: string;
|
|
10
|
+
};
|
|
11
|
+
visible: {
|
|
12
|
+
opacity: number;
|
|
13
|
+
transform?: string;
|
|
14
|
+
};
|
|
15
|
+
keyframes?: string;
|
|
16
|
+
}
|
|
17
|
+
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';
|
|
18
|
+
export declare const animations: Record<string, AnimationConfig>;
|
|
19
|
+
export declare const defaultAnimationOptions: {
|
|
20
|
+
threshold: number;
|
|
21
|
+
rootMargin: string;
|
|
22
|
+
duration: number;
|
|
23
|
+
delay: number;
|
|
24
|
+
once: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function isValidAnimation(animation: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
|
|
29
|
+
* @param offset - Viewport offset (0-100). 0 = bottom trigger, 100 = top trigger
|
|
30
|
+
* @param rootMargin - Custom rootMargin string (takes precedence over offset)
|
|
31
|
+
* @returns rootMargin string for IntersectionObserver
|
|
32
|
+
*/
|
|
33
|
+
export declare function calculateRootMargin(offset?: number, rootMargin?: string): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation configuration and utilities
|
|
3
|
+
* Centralizes animation definitions to reduce duplication
|
|
4
|
+
*/
|
|
5
|
+
export const animations = {
|
|
6
|
+
'fade-in': {
|
|
7
|
+
name: 'fade-in',
|
|
8
|
+
initial: { opacity: 0 },
|
|
9
|
+
visible: { opacity: 1 }
|
|
10
|
+
},
|
|
11
|
+
'fade-in-up': {
|
|
12
|
+
name: 'fade-in-up',
|
|
13
|
+
initial: { opacity: 0, transform: 'translateY(100px)' },
|
|
14
|
+
visible: { opacity: 1, transform: 'translateY(0)' }
|
|
15
|
+
},
|
|
16
|
+
'fade-in-down': {
|
|
17
|
+
name: 'fade-in-down',
|
|
18
|
+
initial: { opacity: 0, transform: 'translateY(-100px)' },
|
|
19
|
+
visible: { opacity: 1, transform: 'translateY(0)' }
|
|
20
|
+
},
|
|
21
|
+
'fade-in-left': {
|
|
22
|
+
name: 'fade-in-left',
|
|
23
|
+
initial: { opacity: 0, transform: 'translateX(-100px)' },
|
|
24
|
+
visible: { opacity: 1, transform: 'translateX(0)' }
|
|
25
|
+
},
|
|
26
|
+
'fade-in-right': {
|
|
27
|
+
name: 'fade-in-right',
|
|
28
|
+
initial: { opacity: 0, transform: 'translateX(100px)' },
|
|
29
|
+
visible: { opacity: 1, transform: 'translateX(0)' }
|
|
30
|
+
},
|
|
31
|
+
'zoom-in': {
|
|
32
|
+
name: 'zoom-in',
|
|
33
|
+
initial: { opacity: 0, transform: 'scale(0.5)' },
|
|
34
|
+
visible: { opacity: 1, transform: 'scale(1)' }
|
|
35
|
+
},
|
|
36
|
+
'zoom-out': {
|
|
37
|
+
name: 'zoom-out',
|
|
38
|
+
initial: { opacity: 0, transform: 'scale(1.5)' },
|
|
39
|
+
visible: { opacity: 1, transform: 'scale(1)' }
|
|
40
|
+
},
|
|
41
|
+
'zoom-in-up': {
|
|
42
|
+
name: 'zoom-in-up',
|
|
43
|
+
initial: { opacity: 0, transform: 'scale(0.5) translateY(100px)' },
|
|
44
|
+
visible: { opacity: 1, transform: 'scale(1) translateY(0)' }
|
|
45
|
+
},
|
|
46
|
+
'zoom-in-left': {
|
|
47
|
+
name: 'zoom-in-left',
|
|
48
|
+
initial: { opacity: 0, transform: 'scale(0.5) translateX(-100px)' },
|
|
49
|
+
visible: { opacity: 1, transform: 'scale(1) translateX(0)' }
|
|
50
|
+
},
|
|
51
|
+
'zoom-in-right': {
|
|
52
|
+
name: 'zoom-in-right',
|
|
53
|
+
initial: { opacity: 0, transform: 'scale(0.5) translateX(100px)' },
|
|
54
|
+
visible: { opacity: 1, transform: 'scale(1) translateX(0)' }
|
|
55
|
+
},
|
|
56
|
+
flip: {
|
|
57
|
+
name: 'flip',
|
|
58
|
+
initial: { opacity: 0, transform: 'perspective(1000px) rotateY(90deg)' },
|
|
59
|
+
visible: { opacity: 1, transform: 'perspective(1000px) rotateY(0deg)' }
|
|
60
|
+
},
|
|
61
|
+
'flip-x': {
|
|
62
|
+
name: 'flip-x',
|
|
63
|
+
initial: { opacity: 0, transform: 'perspective(1000px) rotateX(90deg)' },
|
|
64
|
+
visible: { opacity: 1, transform: 'perspective(1000px) rotateX(0deg)' }
|
|
65
|
+
},
|
|
66
|
+
'slide-rotate': {
|
|
67
|
+
name: 'slide-rotate',
|
|
68
|
+
initial: { opacity: 0, transform: 'translateX(-100px) rotate(-45deg)' },
|
|
69
|
+
visible: { opacity: 1, transform: 'translateX(0) rotate(0deg)' }
|
|
70
|
+
},
|
|
71
|
+
'bounce-in': {
|
|
72
|
+
name: 'bounce-in',
|
|
73
|
+
initial: { opacity: 0, transform: 'scale(0)' },
|
|
74
|
+
visible: { opacity: 1, transform: 'scale(1)' },
|
|
75
|
+
keyframes: `@keyframes bounce {
|
|
76
|
+
0% { transform: scale(0); }
|
|
77
|
+
50% { transform: scale(1.1); }
|
|
78
|
+
100% { transform: scale(1); }
|
|
79
|
+
}`
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
export const defaultAnimationOptions = {
|
|
83
|
+
threshold: 0.5,
|
|
84
|
+
rootMargin: '-10% 0px -10% 0px',
|
|
85
|
+
duration: 800,
|
|
86
|
+
delay: 0,
|
|
87
|
+
once: true
|
|
88
|
+
};
|
|
89
|
+
export function isValidAnimation(animation) {
|
|
90
|
+
return animation in animations;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Calculate rootMargin for IntersectionObserver from offset or custom rootMargin
|
|
94
|
+
* @param offset - Viewport offset (0-100). 0 = bottom trigger, 100 = top trigger
|
|
95
|
+
* @param rootMargin - Custom rootMargin string (takes precedence over offset)
|
|
96
|
+
* @returns rootMargin string for IntersectionObserver
|
|
97
|
+
*/
|
|
98
|
+
export function calculateRootMargin(offset, rootMargin) {
|
|
99
|
+
return rootMargin ??
|
|
100
|
+
(offset !== undefined ? `-${100 - offset}% 0px -${offset}% 0px` : '-10% 0px -10% 0px');
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
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>
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as ScrollAnimate } from './ScrollAnimate.svelte';
|
|
2
|
+
export { default as AnimatedElements } from './AnimatedElements.svelte';
|
|
3
|
+
export { animate } from './animate.svelte';
|
|
4
|
+
export type { AnimateOptions } from './animate.svelte';
|
|
5
|
+
export { useIntersection, useIntersectionOnce } from './useIntersection.svelte';
|
|
6
|
+
export type { IntersectionOptions, UseIntersectionReturn } from './useIntersection.svelte';
|
|
7
|
+
export { animations, isValidAnimation, calculateRootMargin } from './animations';
|
|
8
|
+
export type { AnimationType, AnimationConfig } from './animations';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { default as ScrollAnimate } from './ScrollAnimate.svelte';
|
|
3
|
+
export { default as AnimatedElements } from './AnimatedElements.svelte';
|
|
4
|
+
// Actions
|
|
5
|
+
export { animate } from './animate.svelte';
|
|
6
|
+
// Composables
|
|
7
|
+
export { useIntersection, useIntersectionOnce } from './useIntersection.svelte';
|
|
8
|
+
// Utilities
|
|
9
|
+
export { animations, isValidAnimation, calculateRootMargin } from './animations';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for handling IntersectionObserver logic
|
|
3
|
+
* Reduces duplication between animation components
|
|
4
|
+
*/
|
|
5
|
+
export interface IntersectionOptions {
|
|
6
|
+
threshold?: number | number[];
|
|
7
|
+
rootMargin?: string;
|
|
8
|
+
root?: Element | null;
|
|
9
|
+
}
|
|
10
|
+
export interface UseIntersectionReturn {
|
|
11
|
+
element: HTMLElement | null;
|
|
12
|
+
isVisible: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Track element visibility with IntersectionObserver
|
|
16
|
+
* Updates isVisible whenever visibility changes
|
|
17
|
+
* @param options - IntersectionObserver configuration
|
|
18
|
+
* @param onVisible - Optional callback when visibility changes
|
|
19
|
+
*/
|
|
20
|
+
export declare function useIntersection(options?: IntersectionOptions, onVisible?: (isVisible: boolean) => void): {
|
|
21
|
+
element: HTMLElement | null;
|
|
22
|
+
readonly isVisible: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Track element visibility once (until first trigger)
|
|
26
|
+
* Unobserves after first visibility
|
|
27
|
+
* @param options - IntersectionObserver configuration
|
|
28
|
+
*/
|
|
29
|
+
export declare function useIntersectionOnce(options?: IntersectionOptions): {
|
|
30
|
+
element: HTMLElement | null;
|
|
31
|
+
readonly isVisible: boolean;
|
|
32
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { onMount } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Factory function to create intersection observer composables
|
|
4
|
+
* Eliminates duplication between useIntersection and useIntersectionOnce
|
|
5
|
+
* @param options - IntersectionObserver configuration
|
|
6
|
+
* @param onIntersect - Callback handler for intersection changes
|
|
7
|
+
* @param once - Whether to trigger only once (default: false)
|
|
8
|
+
*/
|
|
9
|
+
function createIntersectionObserver(options = {}, onIntersect, once = false) {
|
|
10
|
+
const { threshold = 0.5, rootMargin = '-10% 0px -10% 0px', root = null } = options;
|
|
11
|
+
let element = $state(null);
|
|
12
|
+
let isVisible = $state(false);
|
|
13
|
+
let hasTriggeredOnce = false;
|
|
14
|
+
let observer = null;
|
|
15
|
+
onMount(() => {
|
|
16
|
+
if (!element)
|
|
17
|
+
return;
|
|
18
|
+
observer = new IntersectionObserver((entries) => {
|
|
19
|
+
entries.forEach((entry) => {
|
|
20
|
+
// For once-only behavior, check if already triggered
|
|
21
|
+
if (once && hasTriggeredOnce)
|
|
22
|
+
return;
|
|
23
|
+
isVisible = entry.isIntersecting;
|
|
24
|
+
onIntersect(entry, entry.isIntersecting);
|
|
25
|
+
// Unobserve after first trigger if once=true
|
|
26
|
+
if (once && entry.isIntersecting) {
|
|
27
|
+
hasTriggeredOnce = true;
|
|
28
|
+
observer?.unobserve(entry.target);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}, {
|
|
32
|
+
threshold,
|
|
33
|
+
rootMargin,
|
|
34
|
+
root
|
|
35
|
+
});
|
|
36
|
+
observer.observe(element);
|
|
37
|
+
return () => {
|
|
38
|
+
observer?.disconnect();
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
get element() {
|
|
43
|
+
return element;
|
|
44
|
+
},
|
|
45
|
+
set element(value) {
|
|
46
|
+
element = value;
|
|
47
|
+
},
|
|
48
|
+
get isVisible() {
|
|
49
|
+
return isVisible;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Track element visibility with IntersectionObserver
|
|
55
|
+
* Updates isVisible whenever visibility changes
|
|
56
|
+
* @param options - IntersectionObserver configuration
|
|
57
|
+
* @param onVisible - Optional callback when visibility changes
|
|
58
|
+
*/
|
|
59
|
+
export function useIntersection(options = {}, onVisible) {
|
|
60
|
+
return createIntersectionObserver(options, (_entry, isVisible) => {
|
|
61
|
+
onVisible?.(isVisible);
|
|
62
|
+
}, false);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Track element visibility once (until first trigger)
|
|
66
|
+
* Unobserves after first visibility
|
|
67
|
+
* @param options - IntersectionObserver configuration
|
|
68
|
+
*/
|
|
69
|
+
export function useIntersectionOnce(options = {}) {
|
|
70
|
+
return createIntersectionObserver(options, () => { }, true);
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rune-scroller",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Lightweight, high-performance scroll animations for Svelte 5. ~2KB bundle, zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "ludoloops",
|
|
10
|
+
"url": "https://lelab.dev"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/ludoloops/rune-scroller"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"svelte",
|
|
18
|
+
"svelte5",
|
|
19
|
+
"animations",
|
|
20
|
+
"scroll-animations",
|
|
21
|
+
"scroll-trigger",
|
|
22
|
+
"intersection-observer",
|
|
23
|
+
"lightweight",
|
|
24
|
+
"performance"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"svelte": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./animations.css": "./dist/animations.css"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"main": "./dist/index.js",
|
|
38
|
+
"svelte": "./dist/index.js",
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"svelte": "^5.0.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"dev": "vite dev",
|
|
45
|
+
"build": "svelte-package",
|
|
46
|
+
"build:demo": "vite build",
|
|
47
|
+
"preview": "vite preview",
|
|
48
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
49
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
50
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
51
|
+
"format": "prettier --write .",
|
|
52
|
+
"lint": "prettier --check . && eslint .",
|
|
53
|
+
"prepublishonly": "pnpm run check && pnpm run build"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@eslint/compat": "^1.4.0",
|
|
57
|
+
"@eslint/js": "^9.36.0",
|
|
58
|
+
"@sveltejs/adapter-auto": "^6.1.0",
|
|
59
|
+
"@sveltejs/kit": "^2.43.2",
|
|
60
|
+
"@sveltejs/package": "^2.5.4",
|
|
61
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
|
62
|
+
"@tailwindcss/vite": "^4.1.13",
|
|
63
|
+
"@types/node": "^22",
|
|
64
|
+
"eslint": "^9.36.0",
|
|
65
|
+
"eslint-config-prettier": "^10.1.8",
|
|
66
|
+
"eslint-plugin-svelte": "^3.12.4",
|
|
67
|
+
"globals": "^16.4.0",
|
|
68
|
+
"prettier": "^3.6.2",
|
|
69
|
+
"prettier-plugin-svelte": "^3.4.0",
|
|
70
|
+
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
71
|
+
"svelte": "^5.39.5",
|
|
72
|
+
"svelte-check": "^4.3.2",
|
|
73
|
+
"tailwindcss": "^4.1.13",
|
|
74
|
+
"typescript": "^5.9.2",
|
|
75
|
+
"typescript-eslint": "^8.44.1",
|
|
76
|
+
"vite": "^7.1.7",
|
|
77
|
+
"vite-plugin-devtools-json": "^1.0.0"
|
|
78
|
+
}
|
|
79
|
+
}
|