π Complete API Reference β Detailed documentation for all features and options.
Lightweight scroll animations for Svelte 5 β Built with Svelte 5 Runes and IntersectionObserver API.
π Open Source by ludoloops at LeLab.dev π Licensed under MIT
prefers-reduced-motiononVisible callback, ResizeObserver support, animation validation, sentinel customizationuseIntersection migrated to Svelte 5 $effect rune for better lifecycle managementProblem:
checkAndWarnIfCSSNotLoaded() was called for EVERY elementdocument.createElement('div')document.body.appendChild(test)getComputedStyle(test) β οΈ Expensive! Forces full page reflowtest.remove()Solution:
// Cache to check only once per page load
let cssCheckResult = null;
export function checkAndWarnIfCSSNotLoaded() {
if (cssCheckResult !== null) return cssCheckResult;
// ... expensive check ...
cssCheckResult = hasAnimation;
return hasAnimation;
}
Impact:
getComputedStyle() calls| Metric | Value |
|---|---|
| Bundle size | 12.4KB (compressed), 40.3KB (unpacked) |
| Initialization | ~1-2ms per element |
| Observer callback | <0.5ms per frame |
| CSS validation | ~0.1ms total (v2.2.0, with cache) |
| Memory per observer | ~1.2KB |
| Animation performance | 60fps maintained |
| Memory leaks | 0 detected |
Layout Thrashing:
Solution:
IntersectionObserver:
CSS Animations:
will-change on visible elements onlyDOM Operations:
insertAdjacentElement('beforebegin') instead of insertBeforeoffsetHeight instead of getBoundingClientRect() (avoids transform issues)Memory Management:
1. will-change Timing
.is-visible { will-change: transform, opacity; }transitionend event to remove will-change2. Threshold Tuning
threshold: 0 (triggers as soon as 1px is visible)threshold: 0.1 or threshold: 0.25threshold: 0 for immediate feedback3. requestIdleCallback
4. Testing on Low-End Devices
Anti-patterns to avoid:
β Premature optimization
β Over-engineering
β Breaking performance for size
β Optimizing unused paths
β Sacrificing maintainability
Recommended approach:
Tools:
npm install rune-scroller
# or
pnpm add rune-scroller
# or
yarn add rune-scroller
<script>
import runeScroller from 'rune-scroller';
</script>
<!-- Simple animation -->
<div use:runeScroller={{ animation: 'fade-in' }}>
<h2>Animated Heading</h2>
</div>
<!-- With custom duration -->
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1500 }}>
<div class="card">Smooth fade and slide</div>
</div>
<!-- Repeat on every scroll -->
<div use:runeScroller={{ animation: 'bounce-in', repeat: true }}>
<button>Bounces on every scroll</button>
</div>
That's it! The CSS animations are included automatically when you import rune-scroller.
For fine-grained control, import CSS manually:
Step 1: Import CSS in your root layout (recommended for SvelteKit):
<!-- src/routes/+layout.svelte -->
<script>
import 'rune-scroller/animations.css';
let { children } = $props();
</script>
{@render children()}
Or import in each component:
<script>
import runeScroller from 'rune-scroller';
import 'rune-scroller/animations.css';
</script>
Step 2: Use the animations
<script>
import runeScroller from 'rune-scroller';
// CSS already imported in layout or above
</script>
<div use:runeScroller={{ animation: 'fade-in' }}>
Animated content
</div>
fade-in - Simple opacity fadefade-in-up - Fade + move up 300pxfade-in-down - Fade + move down 300pxfade-in-left - Fade + move from right 300pxfade-in-right - Fade + move from left 300pxzoom-in - Scale from 0.3 to 1zoom-out - Scale from 2 to 1zoom-in-up - Zoom (0.5β1) + move up 300pxzoom-in-left - Zoom (0.5β1) + move from right 300pxzoom-in-right - Zoom (0.5β1) + move from left 300pxflip - 3D flip on Y-axisflip-x - 3D flip on X-axisslide-rotate - Slide + rotate 10Β°bounce-in - Bouncy entrance (spring effect)interface RuneScrollerOptions {
animation?: AnimationType; // Animation name (default: 'fade-in')
duration?: number; // Duration in ms (default: 800)
repeat?: boolean; // Repeat on scroll (default: false)
debug?: boolean; // Show sentinel as visible line (default: false)
offset?: number; // Sentinel offset in px (default: 0, negative = above)
onVisible?: (element: HTMLElement) => void; // Callback when animation triggers (v2.0.0+)
sentinelColor?: string; // Sentinel debug color, e.g. '#ff6b6b' (v2.0.0+)
sentinelId?: string; // Custom ID for sentinel identification (v2.0.0+)
}
animation - Type of animation to play. Choose from 14 built-in animations listed above. Invalid animations automatically fallback to 'fade-in' with a console warning.duration - How long the animation lasts in milliseconds (default: 800ms).repeat - If true, animation plays every time sentinel enters viewport. If false, plays only once.debug - If true, displays the sentinel element as a visible line below your element. Useful for seeing exactly when animations trigger. Default color is cyan (#00e0ff), customize with sentinelColor.offset - Offset of the sentinel in pixels. Positive values move sentinel down (delays animation), negative values move it up (triggers earlier). Useful for large elements where you want animation to trigger before the entire element is visible.onVisible (v2.0.0+) - Callback function triggered when the animation becomes visible. Receives the animated element as parameter. Useful for analytics, lazy loading, or triggering custom effects.sentinelColor (v2.0.0+) - Customize the debug sentinel color (e.g., '#ff6b6b' for red). Only visible when debug: true. Useful for distinguishing multiple sentinels on the same page.sentinelId (v2.0.0+) - Set a custom ID for the sentinel element. If not provided, an auto-ID is generated (sentinel-1, sentinel-2, etc.). Useful for identifying sentinels in DevTools and tracking which element owns which sentinel.<!-- Basic -->
<div use:runeScroller={{ animation: 'zoom-in' }}>
Content
</div>
<!-- Custom duration -->
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
Fast animation
</div>
<!-- Repeat mode -->
<div use:runeScroller={{ animation: 'bounce-in', repeat: true }}>
Repeats every time you scroll
</div>
<!-- Debug mode - shows cyan line marking sentinel position -->
<div use:runeScroller={{ animation: 'fade-in', debug: true }}>
The cyan line below this shows when animation will trigger
</div>
<!-- Multiple options -->
<div use:runeScroller={{
animation: 'fade-in-up',
duration: 1200,
repeat: true,
debug: true
}}>
Full featured example
</div>
<!-- Large element - trigger animation earlier with negative offset -->
<div use:runeScroller={{
animation: 'fade-in-up',
offset: -200 // Trigger 200px before element bottom
}}>
Large content that needs early triggering
</div>
<!-- Delay animation by moving sentinel down -->
<div use:runeScroller={{
animation: 'zoom-in',
offset: 300 // Trigger 300px after element bottom
}}>
Content with delayed animation
</div>
<!-- v2.0.0: onVisible callback for analytics tracking -->
<div use:runeScroller={{
animation: 'fade-in-up',
onVisible: (el) => {
console.log('Animation visible!', el);
// Track analytics, load images, trigger API calls, etc.
window.gtag?.('event', 'animation_visible', { element: el.id });
}
}}>
Tracked animation
</div>
<!-- v2.0.0: Custom sentinel color for debugging -->
<div use:runeScroller={{
animation: 'fade-in',
debug: true,
sentinelColor: '#ff6b6b' // Red instead of default cyan
}}>
Red debug sentinel
</div>
<!-- v2.0.0: Custom sentinel ID for identification -->
<div use:runeScroller={{
animation: 'zoom-in',
sentinelId: 'hero-zoom',
debug: true
}}>
Identified sentinel (shows "hero-zoom" in debug mode)
</div>
<!-- v2.0.0: Auto-ID (sentinel-1, sentinel-2, etc) -->
<div use:runeScroller={{
animation: 'fade-in-up',
debug: true
// sentinelId omitted β auto generates "sentinel-1", "sentinel-2", etc
}}>
Auto-identified sentinel
</div>
Rune Scroller uses sentinel-based triggering:
Why sentinels?
Automatic ResizeObserver (v2.0.0+)
Works seamlessly with SvelteKit. Simply import rune-scroller in your root layout:
<!-- src/routes/+layout.svelte -->
<script>
import runeScroller from 'rune-scroller';
let { children } = $props();
</script>
{@render children()}
Then use animations anywhere in your app:
<!-- src/routes/+page.svelte -->
<script>
import runeScroller from 'rune-scroller';
</script>
<!-- No special handling needed -->
<div use:runeScroller={{ animation: 'fade-in-up' }}>
Works in SvelteKit SSR!
</div>
The library checks for browser environment and gracefully handles server-side rendering.
Respects prefers-reduced-motion:
/* In animations.css */
@media (prefers-reduced-motion: reduce) {
.scroll-animate {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
Users who prefer reduced motion will see content without animations.
Rune Scroller exports a single action-based API (no components):
runeScroller (default) - Sentinel-based, simple, powerfulWhy actions instead of components?
// CSS is automatically included
import runeScroller from 'rune-scroller';
// Named exports
import {
useIntersection, // Composable
useIntersectionOnce, // Composable
calculateRootMargin // Utility
} from 'rune-scroller';
// Types
import type {
AnimationType,
RuneScrollerOptions,
IntersectionOptions,
UseIntersectionReturn
} from 'rune-scroller';
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';
interface RuneScrollerOptions {
animation?: AnimationType;
duration?: number;
repeat?: boolean;
debug?: boolean;
offset?: number;
onVisible?: (element: HTMLElement) => void; // v2.0.0+
sentinelColor?: string; // v2.0.0+
sentinelId?: string; // v2.0.0+
}
<script>
import runeScroller from 'rune-scroller';
const items = [
{ title: 'Feature 1', description: 'Description 1' },
{ title: 'Feature 2', description: 'Description 2' },
{ title: 'Feature 3', description: 'Description 3' }
];
</script>
<div class="grid">
{#each items as item}
<div use:runeScroller={{ animation: 'fade-in-up', duration: 800 }}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
{/each}
</div>
<div use:runeScroller={{ animation: 'fade-in-down', duration: 1000 }}>
<h1>Welcome</h1>
</div>
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1200 }}>
<p>Engaging content</p>
</div>
<div use:runeScroller={{ animation: 'zoom-in', duration: 1000 }}>
<button>Get Started</button>
</div>
MIT Β© ludoloops
Contributions welcome! Please open an issue or PR on GitHub.
# Development
bun install
bun run dev
bun test
bun run build
Made with β€οΈ by LeLab.dev