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-motionnpm install rune-scroller
# or
pnpm add rune-scroller
# or
yarn add rune-scroller
ā ļø Important: You must import the CSS file once in your app.
Option A - In your root layout (recommended for SvelteKit):
<!-- src/routes/+layout.svelte -->
<script>
import 'rune-scroller/animations.css';
</script>
<slot />
Option B - In each component that uses animations:
<script>
import runeScroller from 'rune-scroller';
import 'rune-scroller/animations.css';
</script>
<script>
import runeScroller from 'rune-scroller';
// CSS already imported in layout or above
</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>
fade-in - Simple opacity fadefade-in-up - Fade + move up 100pxfade-in-down - Fade + move down 100pxfade-in-left - Fade + move from rightfade-in-right - Fade + move from leftzoom-in - Scale from 0.6 to 1zoom-out - Scale from 1.2 to 1zoom-in-up - Zoom + move upzoom-in-left - Zoom + move from rightzoom-in-right - Zoom + move from leftflip - 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: 2000)
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)
}
animation - Type of animation to play. Choose from 14 built-in animations listed above.duration - How long the animation lasts in milliseconds (default: 2000ms).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 cyan line below your element. Useful for seeing exactly when animations trigger.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.<!-- 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>
animate Action (Direct Control)For advanced use cases, use animate for fine-grained IntersectionObserver control:
<script>
import { animate } from 'rune-scroller';
import 'rune-scroller/animations.css';
</script>
<div use:animate={{
animation: 'fade-in-up',
duration: 1000,
delay: 200,
threshold: 0.5,
offset: 20,
once: true
}}>
Advanced control
</div>
Options:
threshold - Intersection ratio to trigger (0-1)offset - Viewport offset percentage (0-100)rootMargin - Custom IntersectionObserver margindelay - Animation delay in msonce - Trigger only once<script>
import { useIntersectionOnce } from 'rune-scroller';
import 'rune-scroller/animations.css';
const intersection = useIntersectionOnce({ threshold: 0.5 });
</script>
<div
bind:this={intersection.element}
class="scroll-animate"
class:is-visible={intersection.isVisible}
data-animation="fade-in-up"
>
Manual control over intersection state
</div>
Rune Scroller uses sentinel-based triggering:
Why sentinels?
Works seamlessly with SvelteKit. Import CSS in your root layout:
<!-- src/routes/+layout.svelte -->
<script>
import 'rune-scroller/animations.css';
</script>
<slot />
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.
// Default export (recommended)
import runeScroller from 'rune-scroller';
// Named exports
import {
animate, // Alternative action
useIntersection, // Composable
useIntersectionOnce, // Composable
calculateRootMargin // Utility
} from 'rune-scroller';
// Types
import type {
AnimationType,
RuneScrollerOptions,
AnimateOptions,
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;
}
interface AnimateOptions {
animation?: AnimationType;
duration?: number;
delay?: number;
threshold?: number;
rootMargin?: string;
offset?: number;
once?: boolean;
}
<script>
import runeScroller from 'rune-scroller';
import 'rune-scroller/animations.css';
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
pnpm install
pnpm dev
pnpm test
pnpm build
Made with ā¤ļø by LeLab.dev