--- 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
Scene
{#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
{#each videoSteps as step, i}
``` #### 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

Section Title

Article content...

``` ### 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: `
August 4, 2025
"Quote text here"
View source
` } ]; ``` ### Hero Header (First Step) ```typescript const heroSteps = [ { raw: true, text: `

Article Title

Subtitle description

` }, // ... 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}

Section Title

...
{#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