fdaudens's picture
Add OSINT and scrolly-sveltekit skills (#1)
2327390
|
raw
history blame contribute delete
20.9 kB
metadata
name: scrolly-sveltekit
description: >-
  Build scroll-driven narrative experiences using SvelteKit 5 and Svelte 5
  runes. Includes components for image scrolly, video scrolly, map scrolly, and
  notice mosaic patterns.
author: Tom
version: 1.0.0
tools: []

Scrollytelling in SvelteKit

A comprehensive guide for building scroll-driven narrative experiences using SvelteKit 5 and Svelte 5's runes.

Quick Start

<script>
  import ScrollySection from '$lib/components/scrolly/ScrollySection.svelte';
  import MyVisualization from './MyVisualization.svelte';

  let activeStep = $state(0);

  const steps = [
    { text: 'First narrative point' },
    { text: 'Second narrative point' },
    { text: 'Third narrative point' }
  ];
</script>

<ScrollySection bind:activeStep={activeStep} {steps}>
  {#snippet children({ activeStep })}
    <MyVisualization {activeStep} />
  {/snippet}
</ScrollySection>

Core Architecture

The Scrolly Pattern

Scrollytelling uses a sticky background + scrolling foreground pattern:

┌─────────────────────────────────┐
│     Sticky Visual Layer         │  ← position: sticky, z-index: 1
│     (image/video/map/viz)       │     Stays fixed while content scrolls
│                                 │
│   ┌─────────────────────────┐   │
│   │    Text Box (step 2)    │   │  ← Text track overlays visuals
│   └─────────────────────────┘   │     z-index: 2
│                                 │
└─────────────────────────────────┘

Key principle: The visual layer uses position: sticky to stay in place while the text track scrolls past, creating the illusion of changing visuals as you scroll.


Component Reference

ScrollySection

The main container component that manages the sticky/scroll relationship.

Location: $lib/components/scrolly/ScrollySection.svelte

Props

Prop Type Default Description
activeStep number 0 Bindable. Current step index
steps Step[] required Array of step content
backgroundColor string '#0a0a0a' Background color
showTextBoxes boolean true Show text boxes for steps
textBoxVariant 'light' | 'dark' 'light' Text box color scheme
textBoxPosition 'center' | 'left' | 'right' 'center' Text box horizontal position
firstStepOffset number 0 Viewport fraction to push first step down
onStepEnter (step, direction) => void - Callback when step changes
onScrollProgress (progress) => void - Callback with 0-1 scroll progress
children Snippet<[{ activeStep }]> - Visualization snippet

Step Interface

interface Step {
  text?: string;           // HTML content for text box
  title?: string;          // Optional title
  image?: string;          // Image URL for text box
  bgColor?: string;        // Custom background color
  raw?: boolean;           // If true, render text without wrapper
  source?: { text: string; url: string };  // Source citation
  imageCredit?: string;    // Photo credit
}

Example Usage

<ScrollySection
  bind:activeStep={heroStep}
  steps={heroSteps}
  backgroundColor="#000000"
  showTextBoxes={true}
  textBoxVariant="light"
  onScrollProgress={(p) => (scrollProgress = p)}
>
  {#snippet children({ activeStep })}
    <HeroVisualization currentImage={heroImages[activeStep]} />
  {/snippet}
</ScrollySection>

ScrollyHelper

IntersectionObserver-based step tracker. Determines which step is most visible.

Location: $lib/components/scrolly/ScrollyHelper.svelte

How It Works

  1. Wraps child elements (steps)
  2. Creates IntersectionObserver for each child
  3. Tracks intersection ratio for each step
  4. Exports value binding with index of most visible step
  5. Returns undefined when no steps are in view

Props

Prop Type Default Description
value number | undefined - Bindable. Current step index
root Element | null null Intersection root
top number 0 Top margin in pixels
bottom number 0 Bottom margin in pixels
increments number 100 Threshold granularity

Usage

<script>
  let currentStep = $state(undefined);
</script>

<ScrollyHelper bind:value={currentStep} top={200} bottom={200}>
  <div class="step">Step 0 content</div>
  <div class="step">Step 1 content</div>
  <div class="step">Step 2 content</div>
</ScrollyHelper>

ScrollyTextBox

Styled text box component for displaying narrative content.

Location: $lib/components/scrolly/ScrollyTextBox.svelte

Props

Prop Type Default Description
title string '' Box title
image string | null null Image URL
bgColor string | null null Custom background
active boolean true Active state
variant 'light' | 'dark' 'light' Color scheme
maxWidth 'narrow' | 'wide' 'narrow' Box width
source { text, url } | null null Source citation
imageCredit string | null null Photo credit
children Snippet - Content slot

Styling

  • Light variant: White background with blur
  • Dark variant: Near-black background with blur
  • Active state: Full opacity, translateY(0)
  • Inactive state: 75% opacity, translateY(8px)
/* Active transition */
.scrolly-text-box {
  opacity: 0.75;
  transform: translateY(8px);
  transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.scrolly-text-box.active {
  opacity: 1;
  transform: translateY(0);
}

Visualization Components

Image Scrolly (HeroVisualization)

Simple image swapper with fade effects.

Location: $lib/components/cleared/HeroVisualization.svelte

<script lang="ts">
  interface Props {
    currentImage: string;
    fadeProgress?: number; // 0-1
  }

  let { currentImage, fadeProgress = 0 }: Props = $props();
</script>

<div class="hero-visualization">
  <img src={currentImage} alt="Scene" class="hero-image" />
  <div class="hero-overlay"></div>
  {#if fadeProgress > 0}
    <div class="fade-overlay" style:opacity={fadeProgress}></div>
  {/if}
</div>

<style>
  .hero-visualization {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    background: #000;
  }

  .hero-image {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .hero-overlay {
    position: absolute;
    inset: 0;
    background: linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.5) 0%,
      rgba(0, 0, 0, 0.3) 50%,
      rgba(0, 0, 0, 0.5) 100%
    );
  }

  .fade-overlay {
    position: absolute;
    inset: 0;
    background: #0a0a0a;
    pointer-events: none;
  }
</style>

Parent Usage

<script>
  let heroStep = $state(0);
  let heroScrollProgress = $state(0);

  const heroImages = [
    '/images/scene1.jpg',
    '/images/scene2.jpg',
    '/images/scene3.jpg'
  ];

  let currentHeroImage = $derived(heroImages[heroStep] ?? heroImages[0]);

  // Fade to black on last step
  let heroFadeProgress = $derived(() => {
    if (heroStep !== 2) return 0;
    const fadeStart = 0.75;
    if (heroScrollProgress < fadeStart) return 0;
    return Math.min(1, (heroScrollProgress - fadeStart) / (1 - fadeStart));
  });
</script>

<ScrollySection
  bind:activeStep={heroStep}
  steps={heroSteps}
  onScrollProgress={(p) => (heroScrollProgress = p)}
>
  {#snippet children({ activeStep })}
    <HeroVisualization
      currentImage={currentHeroImage}
      fadeProgress={heroFadeProgress()}
    />
  {/snippet}
</ScrollySection>

Video Scrolly (VideoScrollyVisualization)

Multi-video display with step-based transitions.

Location: $lib/components/scrolly/VideoScrollyVisualization.svelte

<script lang="ts">
  interface VideoStep {
    videoSrc: string;
    poster?: string;
  }

  interface Props {
    activeStep: number;
    videoSteps: VideoStep[];
  }

  let { activeStep, videoSteps }: Props = $props();

  let videoRefs: HTMLVideoElement[] = $state([]);

  $effect(() => {
    // Pause all, play active
    videoRefs.forEach((video, i) => {
      if (video) {
        if (i === activeStep) {
          video.play().catch(() => {});
        } else {
          video.pause();
          video.currentTime = 0;
        }
      }
    });
  });
</script>

<div class="video-scrolly-viz">
  <div class="video-frame">
    {#each videoSteps as step, i}
      <video
        src={step.videoSrc}
        poster={step.poster}
        bind:this={videoRefs[i]}
        class="video-layer"
        class:active={i === activeStep}
        muted
        loop
        playsinline
        preload="metadata"
      />
    {/each}
  </div>
  <div class="video-vignette"></div>
</div>

<style>
  .video-scrolly-viz {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    background: #000;
  }

  .video-frame {
    position: absolute;
    inset: 1rem;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .video-layer {
    position: absolute;
    width: 100%;
    height: 100%;
    object-fit: contain;
    opacity: 0;
    transition: opacity 500ms ease;
    pointer-events: none;
  }

  .video-layer.active {
    opacity: 1;
  }

  .video-vignette {
    position: absolute;
    inset: 0;
    background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.6) 100%);
    pointer-events: none;
  }
</style>

Parent Usage

<script>
  let videoStep = $state(0);

  const videoData = [
    { videoSrc: '/videos/clip1.mp4' },
    { videoSrc: '/videos/clip2.mp4' },
    { videoSrc: '/videos/clip3.mp4' }
  ];

  const videoSteps = [
    { raw: true, text: '<div class="quote-card">Quote 1</div>' },
    { raw: true, text: '<div class="quote-card">Quote 2</div>' },
    { raw: true, text: '<div class="quote-card">Quote 3</div>' }
  ];
</script>

<ScrollySection
  bind:activeStep={videoStep}
  steps={videoSteps}
  backgroundColor="#000000"
  showTextBoxes={true}
>
  {#snippet children({ activeStep })}
    <VideoScrollyVisualization {activeStep} videoSteps={videoData} />
  {/snippet}
</ScrollySection>

Map Scrolly (MapScrolly)

Interactive map with step-driven camera movements and layer visibility.

Location: $lib/components/cleared/MapScrolly.svelte

Key Features

  • MapTiler/Mapbox GL integration
  • Fly-to animations between steps
  • GeoJSON layer management
  • Fade-in/fade-out overlays

Props

Prop Type Default Description
activeStep number required Current step index
fadeProgress number 0 Fade-in overlay (1=black, 0=visible)
fadeOutProgress number 0 Fade-out overlay at end

Step Data Structure

{
  "steps": [
    {
      "coordinates": [92.9376, 26.2006],
      "zoom": 7,
      "pitch": 0,
      "bearing": 0,
      "duration": 2000,
      "layers": {
        "evictions": { "opacity": 0.6 },
        "villages": { "visible": true, "opacity": 1.0 }
      }
    }
  ]
}

Fly-To Implementation

function flyToStep(index: number) {
  if (!map || !mapReady) return;

  const step = steps[index];
  if (!step) return;

  map.flyTo({
    center: step.coordinates,
    zoom: step.zoom,
    bearing: step.bearing || 0,
    pitch: step.pitch || 0,
    duration: step.duration || 2000,
    easing: (t: number) => t * (2 - t)  // Ease-out quad
  });

  updateLayerVisibility(index);
}

Notice Mosaic (NoticeMosaic)

Scattered document display with progressive reveal.

Location: $lib/components/scrolly/NoticeMosaic.svelte

<script lang="ts">
  interface Notice {
    image: string;
    alt: string;
    title: string;
    subtitle: string;
    excerpt: string;
  }

  interface Props {
    activeStep: number;
    notices: Notice[];
    backgroundColor?: string;
  }

  let { activeStep, notices, backgroundColor = '#1a1715' }: Props = $props();

  let currentNoticeIndex = $derived(Math.min(activeStep, notices.length - 1));

  // Deterministic positioning with jitter
  function getPosition(index: number): [string, string, number] {
    const positions: [number, number, number][] = [
      [20, 15, -3],
      [50, 25, 2.5],
      [15, 45, -1.5],
      [55, 10, 3]
    ];

    const pos = positions[index % positions.length];
    const seed = index * 7919;
    const jitterX = ((seed % 10) - 5) * 0.5;
    const jitterY = (((seed * 3) % 10) - 5) * 0.5;

    return [`${pos[0] + jitterX}%`, `${pos[1] + jitterY}%`, pos[2]];
  }
</script>

Critical CSS Rules

NEVER Use position: fixed for Overlays

Problem: Fixed overlays cover the ENTIRE viewport, including content below.

/* BAD - Will darken entire page */
.fade-overlay {
  position: fixed;
  inset: 0;
  background: #0a0a0a;
  z-index: 5;
}

/* GOOD - Scoped to parent container */
.fade-overlay {
  position: absolute;
  inset: 0;
  background: #0a0a0a;
  pointer-events: none;
}

Z-Index Guidelines

Keep z-index values simple and scoped:

Layer Z-Index Description
Visual layer (sticky) 1 Background visualization
Text track 2 Scrolling text content
Fade overlays 10 Transition effects (inside section)
Footer 10 Ensure footer visibility

AVOID high z-index values on sections adjacent to ScrollySection:

<!-- BAD - Will stack above scrolly -->
<section class="intro relative z-10">

<!-- GOOD - Natural stacking -->
<section class="intro relative">

Sticky Positioning Requirements

For position: sticky to work:

  1. Use overflow-x: clip instead of overflow: hidden:

    :global(html), :global(body) {
      overflow-x: clip;  /* Doesn't break sticky */
    }
    
  2. Add isolation to the scrolly container:

    .scroll-section {
      position: relative;
      isolation: isolate;
    }
    
  3. Visual layer structure:

    .visual-layer {
      position: sticky;
      top: 0;
      width: 100%;
      height: 100vh;
      z-index: 1;
    }
    

Non-Scrolly Content Sections

For regular article content without scroll-lock:

Content Section Structure

<section class="content-section">
  <div class="content-container">
    <h2 class="content-heading">Section Title</h2>
    <div class="prose-content">
      <p>Article content...</p>
    </div>
  </div>
</section>

CSS

/* Dark theme content section */
.content-section {
  position: relative;
  background: #0a0a0a;
  padding: 5rem 1.5rem;
}

.content-container {
  max-width: 42rem;
  margin: 0 auto;
}

.content-heading {
  font-family: 'Playfair Display', Georgia, serif;
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  font-weight: 600;
  line-height: 1.2;
  color: #ffffff;
  margin: 0 0 2rem 0;
}

.prose-content {
  font-family: 'Source Sans 3', system-ui, sans-serif;
  font-size: 1.125rem;
  line-height: 1.85;
  color: rgba(255, 255, 255, 0.85);
}

.prose-content p {
  margin: 0 0 1.5rem 0;
}

.prose-content strong {
  font-weight: 600;
  color: #ffffff;
}

.prose-content blockquote {
  border-left: 4px solid rgba(255, 255, 255, 0.2);
  padding-left: 1.5rem;
  margin: 2rem 0;
  font-style: italic;
  color: rgba(255, 255, 255, 0.6);
}

Timeline Events

.timeline-events {
  margin: 2rem 0;
}

.event {
  padding-left: 1.5rem;
  border-left: 4px solid #dc2626;
  margin-bottom: 1.5rem;
}

.event-date {
  font-weight: 600;
  color: #ffffff;
  margin: 0 0 0.25rem 0;
}

Step Data Patterns

Standard Text Steps

const steps = [
  {
    title: 'September 23, 2021',
    text: 'Event description with <strong>emphasis</strong>.',
    source: { text: 'Reuters', url: 'https://...' },
    imageCredit: 'Photographer Name'
  }
];

Raw HTML Steps (Custom Cards)

const steps = [
  {
    raw: true,
    text: `
      <div class="custom-card">
        <span class="date">August 4, 2025</span>
        <blockquote>"Quote text here"</blockquote>
        <a href="..." class="source-link">View source</a>
      </div>
    `
  }
];

Hero Header (First Step)

const heroSteps = [
  {
    raw: true,
    text: `
      <div class="hero-header">
        <h1 class="hero-title">Article Title</h1>
        <p class="hero-desc">Subtitle description</p>
        <div class="hero-byline">
          <p class="byline-label">By</p>
          <p class="byline-authors">Author Names</p>
          <p class="byline-date">January 2026</p>
        </div>
      </div>
    `
  },
  // ... more steps
];

Fade Effects

Fade-to-Black at Section End

let scrollProgress = $state(0);

// Calculate fade: starts at 75% scroll, complete at 100%
let fadeProgress = $derived(() => {
  const fadeStart = 0.75;
  if (scrollProgress < fadeStart) return 0;
  return Math.min(1, (scrollProgress - fadeStart) / (1 - fadeStart));
});

Fade-in at Section Start

// Fade from black (1) to visible (0) over first 25%
let fadeInProgress = $derived(() => {
  const fadeEnd = 0.25;
  if (scrollProgress >= fadeEnd) return 0;
  return 1 - (scrollProgress / fadeEnd);
});

Applying Fade Overlays

{#if fadeProgress > 0}
  <div class="fade-overlay" style:opacity={fadeProgress}></div>
{/if}

<style>
  .fade-overlay {
    position: absolute;
    inset: 0;
    background: #0a0a0a;
    pointer-events: none;
    z-index: 10;
  }
</style>

Complete Page Structure

<div class="article-container">
  <!-- Hero Scrolly -->
  <ScrollySection bind:activeStep={heroStep} steps={heroSteps}>
    {#snippet children({ activeStep })}
      <HeroVisualization ... />
    {/snippet}
  </ScrollySection>

  <!-- Data Visualization Scrolly -->
  <ScrollySection bind:activeStep={dataStep} steps={dataSteps}>
    {#snippet children({ activeStep })}
      <DataVisualization ... />
    {/snippet}
  </ScrollySection>

  <!-- Regular Content Section -->
  <section class="content-section">
    <div class="content-container">
      <h2>Section Title</h2>
      <div class="prose-content">...</div>
    </div>
  </section>

  <!-- Map Scrolly -->
  <ScrollySection bind:activeStep={mapStep} steps={mapSteps}>
    {#snippet children({ activeStep })}
      <MapScrolly ... />
    {/snippet}
  </ScrollySection>

  <!-- Footer -->
  <footer class="article-footer">...</footer>
</div>

<style>
  .article-container {
    background: #0a0a0a;
    min-height: 100vh;
  }

  :global(html), :global(body) {
    overflow-x: clip;
  }
</style>

Troubleshooting

Video Scrolly Not Visible

  1. Check z-index on surrounding sections (remove z-10)
  2. Ensure no position: fixed overlays covering viewport
  3. Verify video files exist at specified paths

Dark Overlay Covering Entire Page

Cause: Fade overlay using position: fixed instead of absolute

Fix:

.fade-overlay {
  position: absolute;  /* Not fixed! */
  inset: 0;
}

Sticky Not Working

  1. Check for overflow: hidden on ancestors (use overflow-x: clip)
  2. Verify position: sticky has top: 0
  3. Ensure container has defined height or content

Steps Not Triggering

  1. Verify step elements have sufficient height (min-height: 70vh)
  2. Check IntersectionObserver margin settings
  3. Ensure steps are direct children of ScrollyHelper

Typography Guidelines

/* Headings */
font-family: 'Playfair Display', Georgia, serif;

/* Body text */
font-family: 'Source Sans 3', system-ui, sans-serif;

/* Monospace/dates */
font-family: 'JetBrains Mono', 'Courier New', monospace;

Font Loading

<svelte:head>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
  <link
    href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;500;600&display=swap"
    rel="stylesheet"
  />
</svelte:head>

Dependencies

{
  "dependencies": {
    "svelte": "^5.0.0",
    "@sveltejs/kit": "^2.0.0"
  }
}

Optional for specific visualizations:

  • mapbox-gl or @maptiler/sdk for maps
  • d3 for data visualizations
  • gsap for complex animations