# Scrolly Sveltekit

> Scrollytelling in SvelteKit

- Skill: `pas-mllr/scrolly-sveltekit` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add pas-mllr/scrolly-sveltekit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pas-mllr/scrolly-sveltekit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pas-mllr (https://skillmd.com/u/pas-mllr)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/pas-mllr/scrolly-sveltekit

---

# Scrollytelling in SvelteKit

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

## Quick Start

```svelte
<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

```typescript
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

```svelte
<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

```svelte
<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)`

```css
/* 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`

```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

```svelte
<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`

```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

```svelte
<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

```json
{
  "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

```typescript
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`

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

```css
/* 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:

```svelte
<!-- 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`:
   ```css
   :global(html), :global(body) {
     overflow-x: clip;  /* Doesn't break sticky */
   }
   ```

2. **Add isolation** to the scrolly container:
   ```css
   .scroll-section {
     position: relative;
     isolation: isolate;
   }
   ```

3. **Visual layer structure**:
   ```css
   .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

```svelte
<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

```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

```css
.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

```typescript
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)

```typescript
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)

```typescript
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

```typescript
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

```typescript
// 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

```svelte
{#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

```svelte
<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**:
```css
.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

```css
/* 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
<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

```json
{
  "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

