From storytelling
Builds scroll-driven narrative experiences (scrollytelling) in SvelteKit 5 with Svelte 5 runes. Uses sticky backgrounds, IntersectionObserver for steps, image/video/map sections, fades, and text overlays.
npx claudepluginhub buriedsignals/skills --plugin storytellingThis skill uses the workspace's default tool permissions.
A comprehensive guide for building scroll-driven narrative experiences using SvelteKit 5 and Svelte 5's runes.
Implements scroll-driven storytelling experiences with pinned sections, progressive reveals, and scroll-linked animations for narrative web pages.
Builds immersive scroll-driven experiences with parallax storytelling, animations via GSAP ScrollTrigger, Framer Motion, and CSS scroll-timeline. For cinematic web sites like NY Times interactives.
Builds immersive scroll-driven experiences with parallax storytelling, scroll animations, interactive narratives using GSAP ScrollTrigger and Framer Motion. Optimizes performance for cinematic web effects.
Share bugs, ideas, or general feedback.
A comprehensive guide for building scroll-driven narrative experiences using SvelteKit 5 and Svelte 5's runes.
<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>
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.
The main container component that manages the sticky/scroll relationship.
Location: $lib/components/scrolly/ScrollySection.svelte
| 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 |
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
}
<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>
IntersectionObserver-based step tracker. Determines which step is most visible.
Location: $lib/components/scrolly/ScrollyHelper.svelte
value binding with index of most visible stepundefined when no steps are in view| 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 |
<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>
Styled text box component for displaying narrative content.
Location: $lib/components/scrolly/ScrollyTextBox.svelte
| 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 |
translateY(0)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);
}
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>
<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>
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>
<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>
Interactive map with step-driven camera movements and layer visibility.
Location: $lib/components/cleared/MapScrolly.svelte
| 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 |
{
"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 }
}
}
]
}
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);
}
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>
position: fixed for OverlaysProblem: 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;
}
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">
For position: sticky to work:
Use overflow-x: clip instead of overflow: 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;
}
For regular article content without scroll-lock:
<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>
/* 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 {
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;
}
const steps = [
{
title: 'September 23, 2021',
text: 'Event description with <strong>emphasis</strong>.',
source: { text: 'Reuters', url: 'https://...' },
imageCredit: 'Photographer Name'
}
];
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>
`
}
];
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
];
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 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);
});
{#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>
<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>
z-10)position: fixed overlays covering viewportCause: Fade overlay using position: fixed instead of absolute
Fix:
.fade-overlay {
position: absolute; /* Not fixed! */
inset: 0;
}
overflow: hidden on ancestors (use overflow-x: clip)position: sticky has top: 0min-height: 70vh)/* 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;
<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": {
"svelte": "^5.0.0",
"@sveltejs/kit": "^2.0.0"
}
}
Optional for specific visualizations:
mapbox-gl or @maptiler/sdk for mapsd3 for data visualizationsgsap for complex animations