Native Scroll Animations for Svelte 5 ā Built with Svelte 5 Runes and IntersectionObserver API. No external dependencies, pure performance.
š Open Source Project by ludoloops at LeLab.dev š Licensed under MIT ā Contributions welcome!
A modern, lightweight scroll animation library showcasing Svelte 5 capabilities
$state, $props() with snippetsprefers-reduced-motion media query| Scenario | Bundle Size | Impact |
|---|---|---|
| Svelte App (baseline) | ~30-35 KB gzipped | - |
| + AOS Library | ~34-39 KB | +4 KB overhead |
| + Rune Scroller | ~31.9-36.9 KB | +1.9 KB overhead |
| Savings | 2.1 KB | 52% smaller ⨠|
$state() directly (no separate state lib)For a typical SvelteKit app:
rune-scroller-lib/
āāā src/lib/
ā āāā runeScroller.svelte.ts # Main animation action (sentinel-based)
ā āāā useIntersection.svelte.ts # IntersectionObserver composables
ā āāā animate.svelte.ts # Animation action for direct DOM control
ā āāā animations.ts # Animation configuration & validation
ā āāā animations.css # Animation styles (14 animations)
ā āāā animations.test.ts # Animation configuration tests
ā āāā scroll-animate.test.ts # Component behavior tests
ā āāā index.ts # Library entry point
āāā dist/ # Built library (created by pnpm build)
āāā package.json # npm package configuration
āāā svelte.config.js # SvelteKit configuration
āāā vite.config.ts # Vite build configuration
āāā tsconfig.json # TypeScript configuration
āāā eslint.config.js # ESLint configuration
npm install rune-scroller
Or with other package managers:
pnpm add rune-scroller
yarn add rune-scroller
runeScroller ActionUse the runeScroller action with the use: directive for sentinel-based animation triggering:
<script>
import runeScroller from 'rune-scroller';
</script>
<!-- Simple fade in animation -->
<div use:runeScroller={{ animation: 'fade-in' }}>
<h2>Animated Heading</h2>
<p>Animates when scrolled into view</p>
</div>
<!-- With custom duration -->
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1500 }}>
<div class="card">Smooth fade and slide</div>
</div>
<!-- Repeat animation on every scroll -->
<div use:runeScroller={{ animation: 'bounce-in', duration: 800, repeat: true }}>
<button>Bounces on every scroll</button>
</div>
The runeScroller action uses an invisible sentinel element for precise animation triggering:
position: relative if needed (no visual impact)visibility:hidden)debug: true to see the sentinel as a visible cyan line (useful for development)Staggered animations with sentinels:
<script>
import runeScroller from 'rune-scroller';
</script>
<div class="grid">
{#each items as item, i}
<div use:runeScroller={{ animation: 'fade-in-up', duration: 800 }}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
{/each}
</div>
Hero section with sentinel triggering:
<div use:runeScroller={{ animation: 'fade-in-down', duration: 1000 }}>
<h1>Welcome to Our Site</h1>
</div>
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1200 }}>
<p>Engaging content appears as you scroll</p>
</div>
<div use:runeScroller={{ animation: 'zoom-in', duration: 1000 }}>
<button class="cta">Get Started</button>
</div>
runeScroller Optionsinterface RuneScrollerOptions {
animation?: AnimationType; // Animation type (e.g., 'fade-in-up')
duration?: number; // Duration in milliseconds (default: 2000)
repeat?: boolean; // Repeat animation on each scroll (default: false)
}
fade-inSimple opacity fade from transparent to visible.
<script>
import runeScroller from 'rune-scroller';
</script>
<div use:runeScroller={{ animation: 'fade-in' }}>
<h2>Fade In</h2>
<p>Simple fade entrance</p>
</div>
fade-in-upFades in while moving up 100px.
<div use:runeScroller={{ animation: 'fade-in-up' }}>
<h2>Fade In Up</h2>
<p>Rises from below</p>
</div>
fade-in-downFades in while moving down 100px.
<div use:runeScroller={{ animation: 'fade-in-down' }}>
<h2>Fade In Down</h2>
<p>Descends from above</p>
</div>
fade-in-leftFades in while moving left 100px.
<div use:runeScroller={{ animation: 'fade-in-left' }}>
<h2>Fade In Left</h2>
<p>Comes from the right</p>
</div>
fade-in-rightFades in while moving right 100px.
<div use:runeScroller={{ animation: 'fade-in-right' }}>
<h2>Fade In Right</h2>
<p>Comes from the left</p>
</div>
zoom-inScales from 50% to 100% while fading in.
<div use:runeScroller={{ animation: 'zoom-in' }}>
<h2>Zoom In</h2>
<p>Grows into view</p>
</div>
zoom-outScales from 150% to 100% while fading in.
<div use:runeScroller={{ animation: 'zoom-out' }}>
<h2>Zoom Out</h2>
<p>Shrinks into view</p>
</div>
zoom-in-upScales from 50% while translating up 50px.
<div use:runeScroller={{ animation: 'zoom-in-up' }}>
<h2>Zoom In Up</h2>
<p>Grows while moving up</p>
</div>
zoom-in-leftScales from 50% while translating left 50px.
<div use:runeScroller={{ animation: 'zoom-in-left' }}>
<h2>Zoom In Left</h2>
<p>Grows while moving left</p>
</div>
zoom-in-rightScales from 50% while translating right 50px.
<div use:runeScroller={{ animation: 'zoom-in-right' }}>
<h2>Zoom In Right</h2>
<p>Grows while moving right</p>
</div>
flip3D rotation on Y axis (left to right).
<div use:runeScroller={{ animation: 'flip' }}>
<h2>Flip</h2>
<p>Rotates on Y axis</p>
</div>
flip-x3D rotation on X axis (top to bottom).
<div use:runeScroller={{ animation: 'flip-x' }}>
<h2>Flip X</h2>
<p>Rotates on X axis</p>
</div>
slide-rotateSlides from left while rotating 45 degrees.
<div use:runeScroller={{ animation: 'slide-rotate' }}>
<h2>Slide Rotate</h2>
<p>Slides and spins</p>
</div>
bounce-inBouncy entrance with scaling keyframe animation.
<div use:runeScroller={{ animation: 'bounce-in', duration: 800 }}>
<h2>Bounce In</h2>
<p>Bounces into view</p>
</div>
Control animation behavior with the repeat option:
<!-- Plays once on scroll (default) -->
<div use:runeScroller={{ animation: 'fade-in-up' }}>
Animates once when scrolled into view
</div>
<!-- Repeats each time you scroll by -->
<div use:runeScroller={{ animation: 'fade-in-up', repeat: true }}>
Animates every time you scroll past it
</div>
Animate cards with progressive delays:
<script>
import runeScroller from 'rune-scroller';
</script>
<div class="grid">
{#each items as item, i}
<div use:runeScroller={{ animation: 'fade-in-up', duration: 800 + i * 100 }}>
<div class="card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</div>
{/each}
</div>
<script>
import runeScroller from 'rune-scroller';
</script>
<section use:runeScroller={{ animation: 'fade-in' }}>
Content fades in
</section>
<section use:runeScroller={{ animation: 'slide-rotate' }}>
Content slides and rotates
</section>
<section use:runeScroller={{ animation: 'zoom-in', repeat: true }}>
Content zooms in repeatedly
</section>
<script>
import runeScroller from 'rune-scroller';
</script>
<section class="hero">
<h1 use:runeScroller={{ animation: 'fade-in', duration: 1000 }}>
Welcome
</h1>
<p use:runeScroller={{ animation: 'fade-in', duration: 1200 }}>
Scroll to reveal more
</p>
<button use:runeScroller={{ animation: 'zoom-in', duration: 1000 }}>
Get Started
</button>
</section>
The runeScroller action provides sentinel-based animation triggering for precise timing control:
function runeScroller(
element: HTMLElement,
options?: {
animation?: AnimationType; // Animation type
duration?: number; // Duration in ms (default: 2000)
repeat?: boolean; // Repeat animation on each scroll (default: false)
}
): { update?: (newOptions) => void; destroy?: () => void }
Key Features:
Basic Example (One-time animation):
<script>
import runeScroller from 'rune-scroller';
</script>
<!-- Animation plays once when sentinel enters viewport -->
<div use:runeScroller={{ animation: 'fade-in-up', duration: 1000 }}>
Animated content with sentinel-based triggering
</div>
Repeating Animation:
<!-- Animation repeats each time sentinel enters viewport -->
<div use:runeScroller={{ animation: 'bounce-in', duration: 800, repeat: true }}>
This animates every time you scroll past it
</div>
Complete Examples:
<script>
import runeScroller from 'rune-scroller';
</script>
<!-- Fade in once on scroll -->
<div use:runeScroller={{ animation: 'fade-in', duration: 600 }}>
<h2>Section Title</h2>
<p>Fades in when scrolled into view</p>
</div>
<!-- Zoom in with longer duration -->
<div use:runeScroller={{ animation: 'zoom-in-up', duration: 1200 }}>
<div class="card">
<h3>Card Title</h3>
<p>Zooms in from below</p>
</div>
</div>
<!-- Repeating animation for interactive effect -->
<div use:runeScroller={{ animation: 'bounce-in', duration: 700, repeat: true }}>
<button class="interactive-button">Bounces on each scroll</button>
</div>
<!-- Complex staggered layout -->
<div class="grid">
{#each items as item, i}
<div use:runeScroller={{ animation: 'fade-in-up', duration: 800 }}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
{/each}
</div>
When to use:
For one-time animations:
function useIntersectionOnce(options?: {
threshold?: number;
rootMargin?: string;
root?: Element | null;
}): { element: HTMLElement | null; isVisible: boolean }
Returns { element, isVisible } ā bind element to your target, isVisible becomes true once, then observer unobserves.
For repeating animations:
function useIntersection(
options?: {
threshold?: number;
rootMargin?: string;
root?: Element | null;
},
onVisible?: (isVisible: boolean) => void
): { element: HTMLElement | null; isVisible: boolean }
Returns { element, isVisible } ā isVisible toggles true/false on each scroll pass.
For direct DOM animation control without component wrapper:
function animate(
node: HTMLElement,
options?: {
animation?: AnimationType; // Default: 'fade-in'
duration?: number; // Default: 800
delay?: number; // Default: 0
offset?: number; // Optional trigger offset
threshold?: number; // Default: 0
rootMargin?: string; // Optional custom margin
}
): { update?: (newOptions) => void; destroy?: () => void }
Example:
<script>
import { animate } from 'rune-scroller';
</script>
<div use:animate={{ animation: 'fade-in-up', duration: 1000, delay: 200 }}>
Animated content
</div>
Bottom Layer - Browser APIs & Utilities:
Top Layer - Consumer API: 4. runeScroller.svelte.ts - Recommended - Sentinel-based action for scroll animation triggering 5. animate.svelte.ts - Alternative action for direct DOM node animation control
Styles:
cssText for efficient single-statement stylingDOM Utility Extraction
dom-utils.svelte.ts)setCSSVariables() - Centralizes CSS custom property managementsetupAnimationElement() - Consistent animation class/attribute setupcreateSentinel() - Optimized sentinel creation using single cssText statementMemory Leak Fixes
Observer Logic Improvements
animate.svelte.ts to properly handle dynamic threshold/rootMargin changesBundle Size Optimization
.npmignore to exclude test files from npm distribution*.test.ts, *.test.js and built test filescssText usage# Install dependencies
pnpm install
# Dev server
pnpm dev
# Type checking
pnpm check
# Type checking in watch mode
pnpm check:watch
# Format code
pnpm format
# Lint code
pnpm lint
# Build library for npm
pnpm build
# Run tests
pnpm test
# Preview built library
pnpm preview
$state, $props()) as core reactivity primitivesanimations.ts and animations.cssrune-scroller-siterune-scroller on npm registryMIT License ā Free for personal and commercial use.
Made with ā” by ludoloops at LeLab.dev
Open Source Project ā Contributions, issues, and feature requests are welcome!