ScrollReveal
Viewport-entry stagger reveal primitive.
Live demo
011. Default fade-up cascade
Aurora
Conic ribbons drift through gradient space.
Cardwall
A perspective billboard of pinned tiles.
ClickSpark
Wrap-anything click-burst confetti.
RippleGrid
A grid where clicks become waves.
SplitFlap
Mechanical Solari-board character flips.
Marquee
Seamless infinite scroll, pause-on-hover.
2. Slide from left, longer distance
IntersectionObserver-powered
Compositor-side trigger, no scroll listeners.
Per-child stagger
Per-index delay written once into a CSS variable.
One-shot or replay
Reveal once, or re-hide on exit for hero sections.
Reduced-motion safe
Children appear instantly via duration: 0.
3. Per-tile direction grid
4. Live playground
Direction updates immediately. Distance, duration and stagger are read once at mount β press Replay after changing them to remount the stage and see the new values.
Tile A
direction=up, distance=40, stagger=120ms
Tile B
direction=up, distance=40, stagger=120ms
Tile C
direction=up, distance=40, stagger=120ms
Tile D
direction=up, distance=40, stagger=120ms
Tile E
direction=up, distance=40, stagger=120ms
Tile F
direction=up, distance=40, stagger=120ms
Implementation
02<script>
import ScrollReveal from '$lib/components/ScrollReveal.svelte';
</script>
β
<ScrollReveal direction="up" stagger={120} duration={650}>
{#each items as item}
<Card {item} />
{/each}
</ScrollReveal>ScrollReveal is one IntersectionObserver wrapping the children you pass. Each direct child receives a CSS-variable-driven delay so reveals cascade without per-frame JavaScript. Direction, distance, duration, and replay are all overridable per call site.
Logic explainer
03What Does It Do? (Plain English)
ScrollReveal wraps any container and animates its direct children into view as they cross into the viewport. The first child fires immediately, each subsequent child is delayed by a configurable stagger so the row "tucks in" with rhythm rather than flashing on all at once. It is the most common animation request on real marketing pages β the kind of cascade Apple, Stripe, Linear, and Vercel ship by default β rebuilt as a portable Svelte 5 component with no animation library, no scroll listener, and no requestAnimationFrame loop.
The reveal is driven by IntersectionObserver, the browser's compositor-side primitive for "did this element enter the viewport". Once a child has revealed, the observer stops watching it (one-shot mode), so steady-state cost is literally zero β the component does nothing between reveals. Reduced-motion users see all content instantly, in-place; screen readers see all content at all times because we animate via opacity and transform only, never display: none.
How It Works (Pseudo-Code)
state:
containerEl // bound DOM div
observer // IntersectionObserver, created on mount
on mount:
reduced = isReducedMotion()
mode = replay ? 'replay' : 'one-shot'
childEls = Array.from(containerEl.children)
for each child at index i:
set data-sr-index = i
set data-revealed = reduced ? 'true' : 'false'
set --sr-delay = delayForChild(i, stagger, intensity) ms
set --sr-duration = reduced ? 0 : duration ms
set --sr-tx-hidden = transformAtProgress(direction, distance, 0)
set --sr-tx-revealed = transformAtProgress(direction, distance, 1)
if reduced:
return (no observer; everything visible immediately)
observer = new IntersectionObserver((entries) β {
for entry in entries:
target = entry.target
if entry.isIntersecting:
target.dataset.revealed = 'true'
if mode === 'one-shot':
observer.unobserve(target)
else if mode === 'replay':
target.dataset.revealed = 'false'
}, { threshold, rootMargin })
for each child: observer.observe(child)
return () => observer.disconnect()
CSS:
[data-sr-index] {
opacity: 0;
transform: var(--sr-tx-hidden);
transition: opacity var(--sr-duration) cubic-bezier(.22,.61,.36,1) var(--sr-delay),
transform var(--sr-duration) cubic-bezier(.22,.61,.36,1) var(--sr-delay);
}
[data-sr-index][data-revealed='true'] {
opacity: 1;
transform: var(--sr-tx-revealed);
}The component does its work once on mount (stamp children with attributes and CSS variables), creates one observer, and then sleeps until intersection state changes. The observer disconnects on destroy.
The Core Concept: Per-Child Delay Plus Variable-Driven Transforms
Two ideas conspire to give cheap, well-cadenced cascades.
1. Per-index delay is computed once at mount and written into a CSS variable on each child:
delayForChild(index, stagger, intensity) = index * stagger * intensitySo with stagger = 80 and intensity = 1, child 0 has 0 ms delay, child 1 has 80 ms, child 2 has 160 ms, and so on. The CSS transition-delay reads from var(--sr-delay) β when the data-revealed attribute flips, the transition fires, but each child's transition starts at its own pre-computed offset. The cascade is implicit in the CSS engine's transition scheduling; no JS loop is involved.
2. Direction is encoded as a transform rather than baked into a keyframe. transformAtProgress(direction, distance, progress) returns a single CSS transform string for progress = 0 (hidden) and progress = 1 (revealed), and CSS interpolates between them.
direction='up' β translate3d(0, distance px, 0) β translate3d(0, 0, 0)
direction='left' β translate3d(distance px, 0, 0) β translate3d(0, 0, 0)
direction='scale' β scale(0.95) β scale(1)
direction='rotate'β rotate(5deg) β rotate(0deg)The function is exported from the module-script so the maths can be unit-tested without a DOM, and so consumers driving their own scroll-progress logic can call it with a continuous progress value.
reveal cascade (stagger = 100ms)
t=0 ββββββββ
βcard 0β β starts revealing immediately
ββββββββ
t=100 ββββββββ
βcard 1β β starts 100ms later
ββββββββ
t=200 ββββββββ
βcard 2β β 200ms later
ββββββββ
t=300 ββββββββ
βcard 3β
ββββββββCSS Animation Strategy
Two :global() selectors and one @media block carry the entire visual implementation:
.scroll-reveal :global([data-sr-index]) {
opacity: 0;
transform: var(--sr-tx-hidden, none);
transition:
opacity var(--sr-duration, 700ms) cubic-bezier(0.22, 0.61, 0.36, 1) var(--sr-delay, 0ms),
transform var(--sr-duration, 700ms) cubic-bezier(0.22, 0.61, 0.36, 1) var(--sr-delay, 0ms);
will-change: opacity, transform;
}
.scroll-reveal :global([data-sr-index][data-revealed='true']) {
opacity: 1;
transform: var(--sr-tx-revealed, none);
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal :global([data-sr-index]) {
opacity: 1;
transform: none;
transition: none;
}
}:global() is needed because Svelte's CSS scoping would otherwise prefix [data-sr-index] with a hash that the children β rendered through the snippet β don't carry. The selectors are still bounded by the parent .scroll-reveal class, so the rules can't leak across the page.
The cubic-bezier(0.22, 0.61, 0.36, 1) is a soft "ease-out-quint"-flavoured curve β fast at the start, slow at the end. Reveals feel light because the bulk of the visual change happens early in the transition, then it gracefully settles. Linear easing on a cascade looks wooden by comparison.
The reduced-motion @media block is a stylesheet-level safety net: even if the JS path was skipped (SSR-only delivery, matchMedia exception), the user's preference still wins. transition: none is explicit so we don't accidentally animate opacity: 0 β 1 instantly with a 0-duration transition (which some browsers handle inconsistently).
Performance
- Mount cost: O(n) where n is the number of direct children. Five property writes per child plus one
observer.observe(child)call. - Steady state: zero. No
requestAnimationFrame, no scroll listener. TheIntersectionObserverlives on the compositor thread and only fires when intersection state actually changes. - Per reveal cost: one CSS transition per child. The transitioned properties (
opacity,transform) are GPU-composited β the browser does not trigger layout or paint on the affected element. - Recommended bounds: tested to ~200 children per wrapper without frame drops. For very long lists, partition into sub-wrappers per logical section so each observer's callback payload stays bounded.
State Flow Diagram
ββββββββββββββββββββββββββ
β on mount β
β stamp data-sr-index β
β set CSS variables β
ββββββββββββ¬ββββββββββββββ
β
if reduced motion β skip observer
β
βΌ
ββββββββββββββββββββββββββ
β ALL HIDDEN β
β data-revealed=false β
ββββββββββββ¬ββββββββββββββ
β child crosses threshold
βΌ
ββββββββββββββββββββββββββ
β REVEALING child i β β CSS transition fires
β data-revealed=true β with index*stagger delay
ββββββββββββ¬ββββββββββββββ
β
one-shot mode β replay mode
β
βΌ
ββββββββββββββββββββββββββ
β REVEALED β
β observer.unobserve(c) β (one-shot only)
ββββββββββββββββββββββββββ
β child leaves viewport
βΌ (replay only)
ββββββββββββββββββββββββββ
β HIDDEN AGAIN β
β data-revealed=false β
ββββββββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
stagger |
number |
80 |
Milliseconds between consecutive child reveals. |
direction |
'up' | 'down' | 'left' | 'right' | 'scale' | 'rotate' |
'up' |
Which way children enter from. scale and rotate ignore distance. |
distance |
number |
32 |
Translation distance in pixels. Ignored for scale/rotate. |
threshold |
number |
0.15 |
IntersectionObserver threshold (0..1). Share of the child that must be visible to trigger reveal. |
duration |
number |
700 |
Per-child reveal duration in ms. |
intensity |
number |
1 |
Multiplier on the per-step stagger. 0.5 halves the cascade tempo, 2 doubles it. |
replay |
boolean |
false |
If true, children re-hide on viewport exit and re-animate on next entry. |
rootMargin |
string |
'0px' |
IntersectionObserver rootMargin. -100px 0px triggers earlier. |
class |
string |
'' |
Extra classes on the wrapper. Use this for layout (grid, flex). |
children |
Snippet |
β | Direct children to animate. Each becomes one reveal target. |
Edge Cases
| Situation | Behaviour |
|---|---|
prefers-reduced-motion: reduce |
All children mount with data-revealed='true' and --sr-duration: 0ms. Observer is never created. The @media rule is the stylesheet-level safety net. |
| SSR delivery, no JS hydration | Server-rendered children carry no data attributes. They render visible in their natural position via the @media reduced-motion rule (which clamps opacity to 1) β graceful fallback. |
| Children added after mount | The observer was set up against children present at mount. New children are not observed automatically. Re-key the wrapper or call your own observer if dynamic children are required. |
Duplicate :global() selector hits |
The data-sr-index attribute is enough to disambiguate. The parent .scroll-reveal class prevents the rule from leaking to other ScrollReveal-less elements that happen to share the attribute. |
| Long list, observer entry storm on first paint | IntersectionObserver batches entries; the callback handles them in one pass. No throttle is required. |
threshold = 0 with tall children |
Reveal fires the moment any pixel enters. Combined with rootMargin: '-200px', this gives an "anticipate the scroll" feel. |
replay = true and rapid scroll |
Children flip data-revealed between true and false on each crossing. The transition reverses smoothly because both directions are CSS interpolations. |
| Print stylesheets | prefers-reduced-motion handles most cases. For belt-and-braces, layer your own @media print { [data-sr-index] { opacity: 1; transform: none; } }. |
Dependencies
- Svelte 5 β
$props,Snippet,onMount. Module-scope helpers (thresholdForChild,delayForChild,transformAtProgress,shouldReplay,isReducedMotion) exported for unit tests. IntersectionObserverβ broadly supported (Safari 12.1+, Chrome 51+, Firefox 55+). No polyfill ships with the component.- Zero external libraries β no AOS, no GSAP ScrollTrigger, no Framer Motion.
File Structure
src/lib/components/ScrollReveal.svelte # implementation
src/lib/components/ScrollReveal.md # this explainer
src/lib/components/ScrollReveal.test.ts # unit tests covering exported helpers
src/routes/scrollreveal/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
direction | 'up' | 'down' | 'left' | 'right' | 'scale' | 'rotate' | 'up' | Which axis / transform the children reveal from. |
distance | number | 32 | Pixels (or rotation degrees) the children travel during reveal. |
duration | number | 700 | Per-child transition duration (ms). |
stagger | number | 80 | Delay added per index (ms). |
replay | boolean | false | Re-hide on exit so a re-scroll re-animates. |
threshold | number | 0.15 | IntersectionObserver visibility ratio that triggers the reveal. |
rootMargin | string | '0px' | IntersectionObserver root margin β push triggers earlier or later. |
intensity | number | 1 | Multiplier applied to distance for fine-tuning the travel. |