---
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
```svelte
{#snippet children({ activeStep })}
{/snippet}
```
---
## 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
(scrollProgress = p)}
>
{#snippet children({ activeStep })}
{/snippet}
```
---
### 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
Step 0 content
Step 1 content
Step 2 content
```
---
### 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
{#if fadeProgress > 0}
{/if}
```
#### Parent Usage
```svelte
(heroScrollProgress = p)}
>
{#snippet children({ activeStep })}
{/snippet}
```
---
### Video Scrolly (VideoScrollyVisualization)
Multi-video display with step-based transitions.
**Location**: `$lib/components/scrolly/VideoScrollyVisualization.svelte`
```svelte
```
#### Parent Usage
```svelte
{#snippet children({ activeStep })}
{/snippet}
```
---
### 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
```
---
## 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
```
### 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
```
### 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 emphasis.',
source: { text: 'Reuters', url: 'https://...' },
imageCredit: 'Photographer Name'
}
];
```
### Raw HTML Steps (Custom Cards)
```typescript
const steps = [
{
raw: true,
text: `
`
}
];
```
### Hero Header (First Step)
```typescript
const heroSteps = [
{
raw: true,
text: `
`
},
// ... 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}
{/if}
```
---
## Complete Page Structure
```svelte
{#snippet children({ activeStep })}
{/snippet}
{#snippet children({ activeStep })}
{/snippet}
{#snippet children({ activeStep })}
{/snippet}
```
---
## 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
```
---
## 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