Lightweight scroll animations for the DOM. Svelte-first, framework-neutral core.
Built with native IntersectionObserver — zero JS on scroll and GPU-accelerated. Requires a browser with IntersectionObserver support.
🚀 Open Source by ludoloops at LeLab.dev 📜 Licensed under MIT
npm install rune-scroller<script>
import 'rune-scroller/animations.css';
import rs from 'rune-scroller/svelte';
</script>
<div use:rs={{ animation: 'fade-up' }}>Animates on scroll</div>The stylesheet import is always explicit — Rune Scroller never injects CSS automatically, which keeps it safe in Node and edge SSR runtimes.
The root entry exposes a plain animate(element, options) API that works in any framework or with no framework at all. It is free of Svelte runtime imports.
import "rune-scroller/animations.css";
import { animate } from "rune-scroller";
const element = document.querySelector("#hero");
const animation = animate(element, { animation: "fade-up" });
// Update with a complete new option set (replacement semantics)
animation.update({ animation: "zoom-in", duration: 800 });
// Release observers and restore DOM state
animation.destroy();animate is plain DOM, so it works in React, Vue, Angular, or any setup that can hand it an HTMLElement. Call animate(node, options) on mount and animation.destroy() on unmount. For a first-class, rune-based experience, Svelte consumers use the dedicated rune-scroller/svelte entry.
- Svelte-first, framework-neutral core — Native Svelte 5 action plus a Vanilla
animateAPI - Zero dependencies — Pure JS + native IntersectionObserver
- Bundle size — Current minified and gzip figures are published by Bundlephobia above
- 29 primary animations + 7 legacy aliases — Fade, Zoom, Flip, Slide, Bounce
- Zero JS on scroll — Browser handles detection natively
- TypeScript support — Full type definitions for the core and the Svelte entry
- SSR-ready — SvelteKit, Next.js, Nuxt compatible
- GPU-accelerated — CSS transforms via
translate3d - Accessible — Respects
prefers-reduced-motion - No wrapper divs — Your layouts stay intact
- The animated element is observed directly with
IntersectionObserver. - When it enters the viewport, the
is-visibleclass triggers its CSS transition. - Positive
offsetvalues extend the viewport bottom, triggering animations earlier while scrolling down. debug: trueadds a visual sentinel indicator only; it does not affect observation.
No wrapper divs — the original element is observed directly, so flex and grid layouts stay intact.
fade— Simple opacity fadefade-up/fade-down/fade-left/fade-right— Fade + translatefade-up-right/fade-up-left/fade-down-right/fade-down-left— Diagonal fades
zoom-in/zoom-out— Scale in/outzoom-in-up/zoom-in-down/zoom-in-left/zoom-in-right— Zoom + translatezoom-out-up/zoom-out-down/zoom-out-left/zoom-out-right— Zoom out + translate
slide-up/slide-down/slide-left/slide-right— Slide from off-screen
flip-left/flip-right— 3D flip on Y-axisflip-up/flip-down— 3D flip on X-axis
slide-rotate— Slide + rotatebounce-in— Bouncy spring entrance
These v2.x names still work and map to a primary animation: fade-in, fade-in-up, fade-in-down, fade-in-left, fade-in-right, flip, flip-x.
Directional travel distance is controlled by the --rs-distance CSS variable (default: 100px). Only animations that move along an axis honor it:
Honors --rs-distance:
- Directional/diagonal fades:
fade-up,fade-down,fade-left,fade-right,fade-up-right,fade-up-left,fade-down-right,fade-down-left - Directional zooms:
zoom-in-up/zoom-in-down/zoom-in-left/zoom-in-right,zoom-out-up/zoom-out-down/zoom-out-left/zoom-out-right slide-rotate- Legacy directional fades:
fade-in-up,fade-in-down,fade-in-left,fade-in-right
Does not use --rs-distance:
- Opacity-only:
fade,fade-in - Scale-only:
zoom-in,zoom-out - Slide family (
slide-up/slide-down/slide-left/slide-right) — uses100%translates - Flip family (
flip-left/flip-right/flip-up/flip-down, legacyflip,flip-x) — fixed angles bounce-in— scale keyframes
<div style="--rs-distance: 200px" data-animation="fade-up">Farther slide</div>Both the Svelte action and animate accept the same options. update() receives the complete new option set — options you remove are no longer retained.
interface AnimateOptions {
animation?: AnimationType; // default: 'fade-in'
duration?: number; // ms; default: 400
delay?: number; // ms; default: 0
easing?: string; // CSS timing function; default: 'ease'
repeat?: boolean; // re-run on leave/re-enter; default: false
debug?: boolean; // visual trigger indicator only; default: false
offset?: number; // px added to viewport bottom margin; positive = earlier; default: 0
// separate from calculateRootMargin()'s 0–100 percentage helper
threshold?: number | number[]; // IntersectionObserver threshold; default: 0
rootMargin?: string; // if set, overrides the margin derived from offset
// default when omitted: `0px 0px ${offset}px 0px`
observerTarget?: HTMLElement; // observe this node instead of the animated element
sentinelColor?: string; // debug indicator color; default: '#00e0ff'
sentinelId?: string; // debug indicator id
debugLabel?: string; // debug indicator label
onVisible?: (el: HTMLElement) => void; // after `is-visible` is added
onHidden?: (el: HTMLElement) => void; // repeat mode only; after `is-visible` is removed
}
interface AnimateHandle {
update(newOptions?: AnimateOptions): void; // complete replacement
destroy(): void;
}Only one active animate() handle is supported per element. The first handle owns the element: only its update / destroy run. A second animate() / rs call on the same node returns a no-op handle — destroy the owner before creating a new one.
The animate action and core use pixel-based observer offsets. offset: 120 adds 120px to the viewport bottom margin and triggers earlier. threshold defaults to 0, and rootMargin defaults to the margin derived from offset (0px 0px 0px 0px when no offset is provided).
Invalid animation values fall back to 'fade-in' (with a dev-only console warning). bounce-in ignores easing / --easing and always uses cubic-bezier(0.68, -0.55, 0.265, 1.55) (see animations.css).
calculateRootMargin() is a separate percentage-based helper: its offset argument ranges from 0 to 100 and returns a percentage root margin. The Svelte intersection composables use their own defaults (threshold: 0.5 and rootMargin: '-10% 0px -10% 0px'); their options are reactive when passed as a Svelte state object.
// Framework-neutral core (default + named)
import animate, { animate as animateCore } from "rune-scroller";
import { ANIMATION_TYPES, calculateRootMargin } from "rune-scroller";
// Svelte action and rune composables
import rs from "rune-scroller/svelte";
import {
runeScroller,
useIntersection,
useIntersectionOnce,
} from "rune-scroller/svelte";
// Types
import type {
AnimationType,
AnimateOptions,
AnimateHandle,
IntersectionOptions,
UseIntersectionReturn,
} from "rune-scroller";
// IntersectionOptions / UseIntersectionReturn are also valid from "rune-scroller/svelte"useIntersection and useIntersectionOnce live on the Svelte entry. Bind a target by assigning the DOM node to intersection.element (getter/setter); assign null to stop observing. Defaults differ from animate: threshold: 0.5, rootMargin: '-10% 0px -10% 0px', root: null. Options are reactive when passed as a Svelte $state object (the effect re-subscribes). Cleanup runs on unmount or when element is cleared.
useIntersection(options?, onVisible?)—onVisible(isVisible: boolean)fires on every enter/leave transition (not once).useIntersectionOnce(options?)— no callback parameter; unobserves after the first intersect;isVisiblestaystrueafter the first enter.
<script>
import { useIntersection, useIntersectionOnce } from "rune-scroller/svelte";
let node = $state(null);
const intersection = useIntersection({}, (visible) => {
/* boolean on each enter/leave */
});
const once = useIntersectionOnce();
$effect(() => {
intersection.element = node;
once.element = node;
});
</script>
<div bind:this={node}>
visible: {intersection.isVisible}
once: {once.isVisible}
</div>bun run test
bun run check
bun run lint
bun run buildBrowser tests require Playwright's Chromium runtime once per machine:
bunx playwright install chromium
bunx playwright test<script>
import rs from 'rune-scroller/svelte';
const items = ['Item 1', 'Item 2', 'Item 3'];
</script>
{#each items as item, i}
<div use:rs={{ animation: 'fade-up', duration: 800, delay: i * 100 }}>
{item}
</div>
{/each}import { animate } from "rune-scroller";
animate(document.querySelector("h1"), {
animation: "fade-down",
duration: 1000,
});
animate(document.querySelector("p"), { animation: "fade-up", duration: 1200 });
animate(document.querySelector("button"), {
animation: "zoom-in",
duration: 800,
});Respects prefers-reduced-motion — animations are disabled automatically.
- npm: rune-scroller
- GitHub: lelabdev/rune-scroller
- Changelog: CHANGELOG.md
MIT © ludoloops
Made with ❤️ by LeLab.dev
