Components Motion Primitives ScrollReveal

ScrollReveal

Viewport-entry stagger reveal primitive.

Live demo

01

1. 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

1
2
3
4
5
6
7
8
9
10
11
12

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.

Direction
Distance 40px
Duration 700ms
Stagger 120ms
Replay on exit
Trigger

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
ScrollReveal.svelte
<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

03

What 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 * intensity

So 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. The IntersectionObserver lives 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 page

API

04
PropTypeDefaultDescription
direction'up' | 'down' | 'left' | 'right' | 'scale' | 'rotate''up'Which axis / transform the children reveal from.
distancenumber32Pixels (or rotation degrees) the children travel during reveal.
durationnumber700Per-child transition duration (ms).
staggernumber80Delay added per index (ms).
replaybooleanfalseRe-hide on exit so a re-scroll re-animates.
thresholdnumber0.15IntersectionObserver visibility ratio that triggers the reveal.
rootMarginstring'0px'IntersectionObserver root margin β€” push triggers earlier or later.
intensitynumber1Multiplier applied to distance for fine-tuning the travel.