Spaces:
Running
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
- Wraps child elements (steps)
- Creates IntersectionObserver for each child
- Tracks intersection ratio for each step
- Exports
valuebinding with index of most visible step - Returns
undefinedwhen 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:
Use
overflow-x: clipinstead ofoverflow: hidden::global(html), :global(body) { overflow-x: clip; /* Doesn't break sticky */ }Add isolation to the scrolly container:
.scroll-section { position: relative; isolation: isolate; }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
- Check z-index on surrounding sections (remove
z-10) - Ensure no
position: fixedoverlays covering viewport - 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
- Check for
overflow: hiddenon ancestors (useoverflow-x: clip) - Verify
position: stickyhastop: 0 - Ensure container has defined height or content
Steps Not Triggering
- Verify step elements have sufficient height (
min-height: 70vh) - Check IntersectionObserver margin settings
- 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-glor@maptiler/sdkfor mapsd3for data visualizationsgsapfor complex animations