Scroll-Driven
Storytelling
The scroll is not decoration. It is the interaction.
The reader's body — scroll, click, input — is the mechanism of revelation. The story unfolds through participation, not consumption. Every S-tier experience follows one rule: the scroll IS the story.
This is the complete playbook for building Snowfall-caliber immersive narrative web pages. Every effect ranked and explained. Four built examples dissected. The full architecture pattern, from Lenis smooth scroll to sticky sections to fixed overlays to the z-index stack.
Below: the design process, the effect catalogue, four production examples — and the entire skill as one copyable prompt.
The complete prompt is at the bottom — paste it into any agent and build.
Six Steps
Every immersive story starts the same way. Not with code — with six design decisions that define everything the reader will experience.
Define the Signature
Every story needs ONE unique scroll mechanic that IS the story. The cold open, the scan wipe, the countdown clock, the spectral peel. Ask: what is the one interaction that embodies this narrative?
Define the Color Arc
Each story has a unique color journey. Cold to warm, warm to cool, all dark with ember glow, dark lab with neon pulse. The palette shifts as the reader scrolls, reinforcing the emotional arc.
Define the Particle System
Environmental particles set the atmosphere. Ice crystals, scan points, embers and ash, spectral dots. CSS keyframes with mix-blend-mode. Each system is tuned to the story's world.
Define the Telemetry
A fixed HUD that updates with scroll progress. Latitude, longitude, altitude, sensor readings, or a mission clock counting down. Data as narrative device.
Build the Section Flow
A 10-step standard structure: sticky open, signature section, HUD flicker-in, narrative with parallax, horizontal scroll, breakout panel, counters, dark section, pull quote, closing callback.
Generate Images
Generate visuals as PNG, convert to JPEG at quality 82. The signature pair (real and transformed), hero shots, parallax panels, breakout moments. Every image serves the scroll mechanic.
Every Effect, Ranked
Every technique ranked S (jaw-drop) through B (polished). S-Tier effects are the ones that make people stop scrolling to figure out how it was done. A-Tier effects are memorable. B-Tier effects are the polish that makes the whole thing feel finished.
Jaw-Drop
Parallax Depth Stacking
Multiple layers at different scroll factors creating true depth.
Scrollytelling Panels
Sticky section with scroll-proportional state changes.
Clip-Path Scan Wipe
Two images stacked, inset() driven by scroll reveals the layer beneath.
Spectral Layer Peel
Five stacked images, each clips away revealing the next.
Word-by-Word Fade-In
Each word wraps in a span, staggered opacity transitions on enter.
Mission Clock
Non-linear scroll mapping compresses 48 hours into a countdown.
Chapter-Break Interstitials
Full-viewport dark panels with single devastating lines.
Split-Screen Before/After
Scroll-driven clip-path wipe between two images.
Photo Float / Scroll Pan
Overflow hidden, scale(1.3), tiny translate% drift on scroll.
Scroll-Scrubbed Video
Video currentTime tied directly to scroll position.
3D WebGL Scene
Three.js or COBE canvas responding to scroll progress.
Ambient Sound Design
Web Audio API with subtle environmental loops per section.
Text Masking Over Image
Background-clip: text with image or video behind letterforms.
Kinetic Variable Font
Font weight and width animate continuously with scroll.
Memorable
Polished
Our own /about page, scrolled.
We built the Sawfwair /about page using this exact skill. Every row below links to the moment in the page where that technique fires — click a row, land on the scroll position, watch it happen. Then come back and copy the prompt.
Word-scatter fog assembly
Opening confession assembles word-by-word from scattered positions, each rotating into place.
Horizontal chapter clip-path wipe
Four career eras slide in from the right, cut apart by letterbox interstitials.
Character scramble reveal
Era labels materialize as glitchy noise resolving to clean text.
Parallax title with eye-spacing
Depth hero title blurs, scrambles, and its letterforms widen apart as you enter.
Mechanical counters
16 papers. 312 citations. h-index 8. Numbers step upward like a vintage scoreboard.
Quantized film counter
681 films, ticking through 14 panels. The count snaps to whichever beat you’re scrolled into.
Playable scroll-triggered game
Canvas-based dodger game launches mid-page. Initials, leaderboard, the whole deal.
Typewriter paper zoom
A research paper scales from 0.2× to 1.0× while its body typewriter-reveals character by character.
Per-word pull quote with selective glow
Oversized quote materializes word-by-word. Four key words glow with cyan at exactly the right scroll position.
Contribution grid fill
GitHub-style commit grid fills from 12% to 85% as shipping velocity increases.
One Component, One Pattern
Every story is a single self-contained Svelte component. 1,500 to 2,000 lines. No routing, no state management library, no component tree — one file that owns its entire experience.
Lenis Smooth Scroll
Duration 1.2, exponential easing. A single scroll callback dispatches to every update function — sticky, signature, parallax, color arc, telemetry, progress bar.
Sticky Sections
Containers of 250-500vh provide scroll distance. Sections pin with position: sticky. Scroll progress within the container drives every animation.
Fixed Overlays
Progress bar at z-300. Telemetry HUD at z-91. Particle system at z-90. Color arc at z-89. All fixed, all updated by the single scroll callback.
Parallax & Observers
The deepParallax action registers elements for scroll-driven transforms. IntersectionObservers fire one-shot triggers for counters, HUD flicker-in, and word reveals.
The Complete Skill
The entire playbook — architecture, techniques, effect catalogue, and the four signature archetypes — in one copyable prompt. Paste it into Claude Code, Cursor, or any agent with file access and start building.
# Scroll-Driven Storytelling — Complete Playbook
Build Snowfall-caliber immersive narrative pages. Every technique ranked, every implementation detailed.
## When to use this skill
- Building a longform visual story page
- Creating a cinematic scroll experience
- Adding scroll-driven animations to any page
- Designing a narrative arc expressed through scroll interaction
## Core Philosophy
The reader’s body — scroll, click, input — is the mechanism of revelation. The story unfolds through participation, not consumption. Every S-tier experience follows this rule: **the scroll IS the story**.
## Architecture Pattern
Every immersive story page follows this structure:
1. **Single self-contained Svelte component** (~1500-2000 lines)
2. **Lenis smooth scroll** driving a unified scroll callback
3. **Sticky sections** for scroll-proportional reveals (250-500vh containers)
4. **Fixed overlays** (particles, progress bar, telemetry, color arc)
5. **IntersectionObserver** for one-shot triggers (counters, HUD flicker)
6. **\`deepParallax\` action** for parallax registration
7. **Scoped CSS** with \`:global()\` for dynamic classes
## Effect Catalogue
Every technique ranked S (jaw-drop) → A (memorable) → B (polished) → C (functional).
### S-Tier (Jaw-Drop)
| Effect | Implementation |
|---|---|
| **Parallax depth stacking** | Multiple layers at different \`deepParallax\` factors (0.05, 0.15, -0.05) |
| **Scrollytelling panels** | Sticky section + scroll-proportional state changes |
| **Clip-path scan wipe** | Two images stacked, \`clipPath: inset()\` driven by scroll |
| **Spectral layer peel** | 5 stacked images, each clips away revealing the next |
| **Word-by-word fade-in** | \`revealWords\` action wrapping each word in \`<span>\` |
| **Mission clock with time compression** | Countdown driven by non-linear scroll mapping |
| **Chapter-break interstitials** | Full-viewport dark panels with single devastating lines |
| **Split-screen before/after** | Scroll-driven clip-path wipe between two images |
| **Photo float / scroll pan** | \`overflow: hidden\` + \`scale(1.3)\` + tiny \`translate%\` drift |
| **Scroll-scrubbed video** | Video \`currentTime\` tied to scroll position |
| **3D WebGL scene** | Three.js/COBE canvas responding to scroll |
| **Ambient sound design** | Web Audio API, subtle env loops per section |
| **Text masking over image** | \`background-clip: text\` with image/video behind |
| **Kinetic variable font** | Font weight/width animates with scroll |
### A-Tier (Memorable)
| Effect | Implementation |
|---|---|
| **Horizontal scroll chapter** | Tall container + sticky section + \`translateX\` on track |
| **Section color transitions** | Fixed overlay with \`mix-blend-mode: soft-light\`, RGB interpolated |
| **Sticky background transitions** | Background crossfades between chapters via opacity |
| **Custom cursor / cursor trail** | CSS cursor: none + JS-driven follow element |
| **Letterbox mode** | Animated black bars via \`::before\`/\`::after\` on viewport |
| **Scroll-driven counter** | rAF counter triggered by IntersectionObserver |
| **Character scramble** | Text "unencrypts" from random chars to real string |
| **HUD flicker-in** | \`@keyframes hudFlicker\` with \`steps(4)\` |
| **Oversized pull quotes** | 80vh min-height, centered, \`clamp(1.5rem, 3.5vw, 2.75rem)\` |
| **Horizontal scroll ticker** | Marquee-style repeating text strip as chapter divider |
### B-Tier (Polished)
| Effect | Implementation |
|---|---|
| **Reveal on enter** | IntersectionObserver adds \`.shown\` class, CSS transition |
| **Width-clip text reveal** | \`max-width: 0\` → \`max-width: 500px\` transition on \`.shown\` |
| **Gradient section blends** | Bottom gradient fading to next section’s color |
| **Progress bars** | \`scaleX(progress)\` with gradient fill |
| **Telemetry HUD** | Fixed panel with scroll-interpolated mission data |
| **Particle systems** | CSS-only dots with \`mix-blend-mode\` (screen/multiply) |
## Designing a New Story
### Step 1: Define the Signature
Every story needs ONE unique scroll mechanic that IS the story. Common archetypes:
- **Cold open** — environmental state dissolves, landscape reveals, title layers in letter-by-letter
- **Scan wipe** — reality dissolves into abstraction via clip-path (real → wireframe, photo → schematic)
- **Countdown clock** — compressed time racing to zero with non-linear scroll mapping
- **Spectral layer peel** — stacked images revealing sequential transformation (bands, phases, seasons)
Ask: "What is the ONE interaction that embodies this narrative?"
### Step 2: Define the Color Arc
Each story needs a unique color journey. Common arcs:
- **Cold→warm** (ice blue → amber)
- **Warm→cool** (golden → digital blue)
- **All dark** (charcoal + ember glow throughout)
- **Dark lab** (black + neon accent pulse)
### Step 3: Define the Particle System
Each story needs environmental particles. Common systems:
- **Ice crystals** — white, \`mix-blend-mode: screen\`, organic drift + bokeh
- **Scan points** — colored dots, \`multiply\`, geometric
- **Embers + ash** — orange rising + gray falling, \`screen\`
- **Spectral dots** — mixed accent colors, \`screen\`
### Step 4: Define the Telemetry
Each story needs a unique fixed HUD. Common readouts:
- **Telemetry** — LAT/LON/ALT/BAT/TMP/SPD
- **Metrics telemetry** — LAT/LON/ALT/COV/RES/PTS
- **Mission clock** — 48:00:00 countdown in accent mono
- **Sensor readout** — BAND/λ/INDEX/RES/ALT
### Step 5: Build the Section Flow
Standard structure (adapt per story):
1. **Sticky Open** (250vh) — Date eyebrow, title letter-by-letter, image reveals, subtitle
2. **Signature Section** (400-500vh sticky) — The unique scroll mechanic
3. **HUD** — Flicker-in mission data panel
4. **Narrative + Parallax** — Editorial text with parallax windows/panels
5. **Horizontal Scroll** — Timeline, fleet, or process cards
6. **Parallax Panel** — Breakout image moment with text
7. **By The Numbers** — Counter animations
8. **Dark Section** — Emotional weight, key revelation
9. **Pull Quote** — Word-by-word reveal
10. **Closing** — Callback to title, parallax window, CTA
### Step 6: Generate Images
Use the \`image\` CLI to generate visuals. Always:
- Generate as PNG, convert to JPEG at quality 82 via \`sips\`
- Store in \`static/images/stories/{slug}/\`
- Generate the signature pair (e.g., real + wireframe for wipe effects)
- Generate hero, parallax panels, breakout moments
## Implementation Checklist
- [ ] Lenis initialized with \`duration: 1.2\`, exponential easing
- [ ] Single \`lenis.on('scroll')\` callback dispatching to all update functions
- [ ] Sticky sections with \`position: sticky; top: 0\` inside tall containers
- [ ] \`deepParallax\` action registered, updated in scroll callback
- [ ] Story progress bar (fixed, z-300, gradient fill)
- [ ] Particle system (CSS keyframes, \`mix-blend-mode\`)
- [ ] Color arc overlay (fixed, \`soft-light\` blend)
- [ ] Telemetry/clock HUD (fixed, bottom-right)
- [ ] IntersectionObservers for HUD flicker + counter triggers
- [ ] \`revealWords\` action for pull quotes
- [ ] \`use:reveal\` on standard scroll-enter elements
- [ ] Mobile responsive (stacks, hides fixed overlays)
- [ ] \`prefers-reduced-motion\` respected
- [ ] Lenis cleanup in onMount return
---
# Page Architecture — Boilerplate
## Script Block Structure
\`\`\`svelte
<script lang="ts">
import { reveal } from '$lib/reveal'
import { onMount } from 'svelte'
import Lenis from 'lenis'
import 'lenis/dist/lenis.css'
/* ── State ($state for template-reactive values) ───────── */
let stickyDone = $state(false)
let hudVisible = $state(false)
let counterValues = $state([0, 0, 0, 0])
// ... story-specific state
/* ── Refs (plain let for bind:this) ──────────────────── */
let stickyWrapEl: HTMLElement
let storyProgressEl: HTMLElement
let colorArcEl: HTMLElement
// ... all element refs
/* ── Constants ─────────────────────────────────────── */
const titleChars = 'Title Here'.split('')
const hudItems = [...]
const stats = [...]
const particles = [...]
// ... story-specific data
/* ── Deep Parallax System ──────────────────────────── */
type ParallaxEntry = { node: HTMLElement; parent: HTMLElement; factor: number; scale?: number }
let parallaxEntries: ParallaxEntry[] = []
function deepParallax(node: HTMLElement, opts: number | { factor: number; scale: number } = 0.15) {
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return { destroy() {} }
const parent = node.parentElement
if (!parent) return { destroy() {} }
const factor = typeof opts === 'number' ? opts : opts.factor
const scale = typeof opts === 'number' ? undefined : opts.scale
const entry: ParallaxEntry = { node, parent, factor, scale }
parallaxEntries.push(entry)
return { destroy() { parallaxEntries = parallaxEntries.filter(e => e !== entry) } }
}
/* ── RevealWords Action ────────────────────────────── */
function revealWords(node: HTMLElement) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
node.classList.add('shown')
return { destroy() {} }
}
const p = node.querySelector('p')
if (!p) return { destroy() {} }
const words = p.textContent!.trim().split(/\s+/)
p.innerHTML = words
.map((w, i) => \`<span class="word" style="transition-delay:\${i * 40}ms">\${w}</span>\`)
.join(' ')
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { node.classList.add('shown'); obs.unobserve(node) }
}, { threshold: 0.3, rootMargin: '0px 0px -80px 0px' })
obs.observe(node)
return { destroy: () => obs.disconnect() }
}
/* ── Helpers ───────────────────────────────────────── */
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
function animateCounters() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
counterValues = stats.map(s => s.target)
return
}
const duration = 1500, start = performance.now()
function tick(now: number) {
const t = Math.min((now - start) / duration, 1)
const ease = 1 - Math.pow(1 - t, 3)
counterValues = stats.map(s => s.target * ease)
if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
function fmtStat(i: number): string {
const v = counterValues[i], s = stats[i]
if (s.decimals > 0) return v.toFixed(s.decimals) + s.suffix
return Math.round(v) + s.suffix
}
/* ── Lifecycle ─────────────────────────────────────── */
onMount(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// Skip animations if reduced motion
if (reduced) { /* set all final states */ }
// Lenis
const lenis = new Lenis({
duration: 1.2,
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
})
lenis.on('scroll', ({ scroll, progress }: { scroll: number; progress: number }) => {
updateSticky()
updateSignature() // story-specific signature section
updateHorizontalScroll() // if applicable
updateParallax()
updateColorArc(progress)
updateTelemetry(progress) // or updateClock(progress)
updateStoryProgress(progress)
})
function raf(time: number) { lenis.raf(time); requestAnimationFrame(raf) }
requestAnimationFrame(raf)
// Scroll update functions
function updateSticky() {
if (!stickyWrapEl) return
const rect = stickyWrapEl.getBoundingClientRect()
const scrollRange = stickyWrapEl.clientHeight - window.innerHeight
if (scrollRange <= 0) return
const p = Math.max(0, Math.min(-rect.top / scrollRange, 1))
// Drive elements with scroll progress p (0 to 1)
}
function updateParallax() {
const viewH = window.innerHeight
for (const e of parallaxEntries) {
const r = e.parent.getBoundingClientRect()
if (e.scale) {
const center = r.top + r.height / 2 - viewH / 2
const pct = (center / viewH) * -5
e.node.style.transform = \`translate(0%, \${pct}%) scale(\${e.scale})\`
} else {
e.node.style.transform = \`translate3d(0, \${r.top * e.factor}px, 0)\`
}
}
}
function updateStoryProgress(progress: number) {
if (storyProgressEl) storyProgressEl.style.transform = \`scaleX(\${progress})\`
}
// IntersectionObservers
const hudObs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { hudVisible = true; hudObs.disconnect() }
}, { threshold: 0.25 })
if (hudEl) hudObs.observe(hudEl)
const statsObs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { animateCounters(); statsObs.disconnect() }
}, { threshold: 0.3 })
if (statsEl) statsObs.observe(statsEl)
return () => {
lenis.destroy()
hudObs.disconnect()
statsObs.disconnect()
}
})
</script>
\`\`\`
## Sticky Section Pattern
Container provides scroll distance. Section pins in place. Scroll progress drives animation.
\`\`\`html
<div class="sticky-wrap" bind:this={stickyWrapEl} style="height: 300vh">
<section class="sticky-section" style="position: sticky; top: 0; height: 100vh">
<!-- Content driven by scroll progress -->
</section>
</div>
\`\`\`
## Horizontal Scroll Pattern
Same as sticky, but inner content translates horizontally.
\`\`\`html
<div class="horiz-wrap" bind:this={horizWrapEl} style="height: 500vh">
<section class="horiz-section" style="position: sticky; top: 0; height: 100vh; overflow: hidden">
<div class="horiz-track" bind:this={horizTrackEl} style="display: flex; will-change: transform">
<!-- Cards laid out in a row -->
</div>
</section>
</div>
\`\`\`
\`\`\`typescript
function updateHorizontalScroll() {
if (!horizWrapEl || !horizTrackEl) return
const rect = horizWrapEl.getBoundingClientRect()
const scrollRange = horizWrapEl.clientHeight - window.innerHeight
if (scrollRange <= 0) return
const p = Math.max(0, Math.min(-rect.top / scrollRange, 1))
const maxTravel = Math.max(0, horizTrackEl.scrollWidth - window.innerWidth + 80)
horizTrackEl.style.transform = \`translate3d(\${-p * maxTravel}px, 0, 0)\`
}
\`\`\`
## Parallax Window Pattern (ICOMAT-style)
Image is \`scale(1.3)\` inside \`overflow: hidden\` container. Drifts by ±5% as you scroll.
\`\`\`html
<div class="parallax-window" style="overflow: hidden; height: 50vh">
<img src="..." use:deepParallax={{ factor: 0.15, scale: 1.3 }} />
</div>
\`\`\`
## Reveal Pattern
\`\`\`css
.reveal-item {
opacity: 0;
transform: translateY(20px);
transition: opacity 680ms var(--ease-out), transform 680ms var(--ease-out);
}
.reveal-item:global(.shown) {
opacity: 1;
transform: translateY(0);
}
\`\`\`
## Fixed Overlay Stack
\`\`\`
z-index: 300 — Story progress bar
z-index: 200 — Sticky sections (during open phase, drops to 1 after)
z-index: 100 — Navigation (from layout)
z-index: 91 — Telemetry / Clock HUD
z-index: 90 — Particle system
z-index: 89 — Color arc overlay
\`\`\`
## Mobile Pattern (max-width: 700px)
- Sticky sections: still work (well-supported)
- Horizontal scroll: collapse to vertical stack (\`height: auto\`, \`position: relative\`)
- Parallax windows: reduce height to 250px
- Telemetry/clock: \`display: none\`
- Progress bars: \`display: none\`
- Grids: single column
- Parallax panels: single column
---
# Technique Implementation Reference
## COBE Globe (S-Tier — 3D WebGL Scene)
Reusable Svelte action at \`src/lib/globe.ts\`. One-liner to add a rotating 3D globe with location markers to any page.
\`\`\`svelte
<script>
import { storyGlobe } from '$lib/globe'
</script>
<div class="globe-wrap">
<canvas use:storyGlobe={{
markers: [
{ location: [39.76, -121.62], size: 0.08 },
],
markerColor: [0.91, 0.63, 0.13],
glowColor: [0.0, 0.47, 0.75],
mapBrightness: 4,
speed: 0.002,
theta: 0.2,
}} />
</div>
\`\`\`
CSS for the globe container:
\`\`\`css
.globe-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 0;
transition: opacity 1.2s ease-out;
}
.globe-wrap canvas {
width: min(650px, 75vw);
height: min(650px, 75vw);
display: block;
}
.dark-hero.done .globe-wrap {
opacity: 0;
pointer-events: none;
}
\`\`\`
The action auto-computes \`phi\` from the first marker’s longitude to focus the globe on the story’s location. Handles retina sizing, animation loop, reduced motion, and cleanup.
---
## Clip-Path Wipe (Scan / Before-After)
Two images stacked. Top image clips away revealing the image beneath.
**Horizontal wipe (left to right):**
\`\`\`typescript
scanRealEl.style.clipPath = \`inset(0 0 0 \${progress * 100}%)\`
\`\`\`
**Vertical wipe (bottom to top, for spectral peel):**
\`\`\`typescript
layerEl.style.clipPath = \`inset(0 0 \${progress * 100}% 0)\`
\`\`\`
**Glow line tracking the clip edge:**
\`\`\`css
.scan-line {
position: absolute; top: 0; bottom: 0; width: 2px; z-index: 3;
background: var(--accent);
box-shadow: 0 0 15px 3px rgba(var(--accent-rgb), 0.4),
0 0 40px 8px rgba(var(--accent-rgb), 0.15);
opacity: 0; pointer-events: none; will-change: left;
}
\`\`\`
**Text overlays that fade in/out at scroll thresholds:**
\`\`\`typescript
function scanTextOpacity(p: number, enterAt: number, holdUntil: number): number {
if (p < enterAt) return 0
if (p < enterAt + 0.06) return (p - enterAt) / 0.06
if (p < holdUntil) return 1
if (p < holdUntil + 0.06) return 1 - (p - holdUntil) / 0.06
return 0
}
\`\`\`
## Mission Clock with Time Compression
Non-linear mapping from scroll progress to hours remaining:
\`\`\`typescript
function progressToHours(p: number): number {
if (p < 0.6) return 48 - (p / 0.6) * 24 // Slow: 48→24 over 60%
if (p < 0.9) return 24 - ((p - 0.6) / 0.3) * 22 // Fast: 24→2 over 30%
return 2 - ((p - 0.9) / 0.1) * 2 // Racing: 2→0 over 10%
}
function formatClock(hours: number): string {
const h = Math.floor(Math.max(0, hours))
const m = Math.floor((Math.max(0, hours) % 1) * 60)
const s = Math.floor(((Math.max(0, hours) * 60) % 1) * 60)
return \`\${String(h).padStart(2, '0')}:\${String(m).padStart(2, '0')}:\${String(s).padStart(2, '0')}\`
}
\`\`\`
## Particle Systems
### Snow / Ice Crystals
\`\`\`typescript
const particles = [
...Array.from({ length: 18 }, () => ({
x: Math.random() * 100,
dur: 18 + Math.random() * 30,
delay: -(Math.random() * 30),
size: 1 + Math.random() * 1.2,
opacity: 0.04 + Math.random() * 0.1,
drift: -40 + Math.random() * 80,
blur: 0,
})),
...Array.from({ length: 6 }, () => ({
x: Math.random() * 100,
dur: 25 + Math.random() * 35,
delay: -(Math.random() * 40),
size: 6 + Math.random() * 10,
opacity: 0.02 + Math.random() * 0.035,
drift: -60 + Math.random() * 120,
blur: 3 + Math.random() * 4,
})),
]
\`\`\`
**Falling (snow/ash/scan points):**
\`\`\`css
@keyframes drift-down {
0% { transform: translateY(-10px) translateX(0); }
40% { transform: translateY(40vh) translateX(calc(var(--drift) * 0.6)); }
70% { transform: translateY(70vh) translateX(calc(var(--drift) * 1.1)); }
100% { transform: translateY(105vh) translateX(var(--drift)); }
}
\`\`\`
**Rising (embers):**
\`\`\`css
@keyframes drift-up {
0% { transform: translateY(105vh) translateX(0); }
40% { transform: translateY(65vh) translateX(calc(var(--drift) * 0.5)); }
70% { transform: translateY(30vh) translateX(calc(var(--drift) * 0.9)); }
100% { transform: translateY(-10px) translateX(var(--drift)); }
}
\`\`\`
**Blend modes:**
- White particles on dark bg: \`mix-blend-mode: screen\`
- Colored particles on light bg: \`mix-blend-mode: multiply\`
## HUD Flicker Animation
\`\`\`css
.hud-row {
opacity: 0;
}
.hud-row.visible {
animation: hudFlicker 500ms steps(4) both;
}
@keyframes hudFlicker {
0% { opacity: 0; }
25% { opacity: 0.5; }
50% { opacity: 0.1; }
75% { opacity: 0.85; }
100% { opacity: 1; }
}
\`\`\`
Scanline sweep:
\`\`\`css
.hud-scanline {
position: absolute; top: -2px; left: 0; width: 100%; height: 2px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.25), transparent);
animation: scanline 3s linear infinite;
}
@keyframes scanline {
from { top: -2px; }
to { top: calc(100% + 2px); }
}
\`\`\`
## Letter-by-Letter Title Reveal
Driven by scroll progress within a sticky section:
\`\`\`typescript
if (heroTextEl) {
const chars = heroTextEl.querySelectorAll('.char') as NodeListOf<HTMLElement>
chars.forEach((el, i) => {
const threshold = 0.3 + (i / chars.length) * 0.3
const cp = Math.max(0, Math.min((p - threshold) / 0.04, 1))
el.style.opacity = String(cp)
el.style.transform = \`translateY(\${(1 - cp) * 30}px)\`
})
}
\`\`\`
Template:
\`\`\`svelte
<h1 class="headline">
{#each titleChars as char, i}
<span class="char">{char === ' ' ? '\u00A0' : char}</span>
{/each}
</h1>
\`\`\`
## Width-Clip Text Reveal (Greydient-style)
\`\`\`css
.title-clip {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 0;
transition: max-width 800ms var(--ease-out);
}
.title-clip-container:global(.shown) .title-clip {
max-width: 500px;
}
\`\`\`
## Color Arc Overlay
Fixed div with \`mix-blend-mode: soft-light\`, background color interpolated from scroll:
\`\`\`typescript
function updateColorArc(progress: number) {
const r = Math.round(lerp(startR, endR, progress))
const g = Math.round(lerp(startG, endG, progress))
const b = Math.round(lerp(startB, endB, progress))
colorArcEl.style.background = \`rgba(\${r}, \${g}, \${b}, 0.06)\`
}
\`\`\`
**Ember glow variant** (intensifies mid-page, fades at bookends):
\`\`\`typescript
const intensity = progress < 0.5 ? progress * 2 : 2 - progress * 2
colorArcEl.style.background = \`rgba(232, 160, 32, \${0.04 * intensity})\`
\`\`\`
## Telemetry Interpolation
\`\`\`typescript
function interpolateTelem(p: number) {
const n = waypoints.length - 1
const idx = Math.min(Math.floor(p * n), n - 1)
const t = (p * n) - idx
const a = waypoints[Math.max(0, idx)]
const b = waypoints[Math.min(idx + 1, n)]
return {
field1: lerp(a.field1, b.field1, t),
field2: lerp(a.field2, b.field2, t).toFixed(4),
}
}
\`\`\`
## Character Scramble (Not Yet Built)
\`\`\`typescript
function scramble(node: HTMLElement, { duration = 800, chars = '!@#$%^&*()_+' } = {}) {
const target = node.textContent!
const start = performance.now()
function tick(now: number) {
const t = Math.min((now - start) / duration, 1)
const resolved = Math.floor(t * target.length)
node.textContent = target.slice(0, resolved) +
Array.from({ length: target.length - resolved }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('')
if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
\`\`\`
## Letterbox Mode (Not Yet Built)
\`\`\`css
.letterbox::before,
.letterbox::after {
content: '';
position: fixed;
left: 0; right: 0;
height: 0;
background: black;
z-index: 250;
transition: height 600ms var(--ease-out);
}
.letterbox::before { top: 0; }
.letterbox::after { bottom: 0; }
.letterbox.active::before,
.letterbox.active::after {
height: 12vh;
}
\`\`\`
## Custom Cursor (Not Yet Built)
\`\`\`css
.story-page { cursor: none; }
.custom-cursor {
position: fixed;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
pointer-events: none;
z-index: 9999;
mix-blend-mode: difference;
transition: transform 80ms ease-out;
}
\`\`\`
\`\`\`typescript
let cx = 0, cy = 0
document.addEventListener('mousemove', (e) => {
cx = e.clientX; cy = e.clientY
cursorEl.style.transform = \`translate(\${cx - 4}px, \${cy - 4}px)\`
})
\`\`\`
---
# Signature Archetypes
Every story has ONE signature mechanic. Pick the archetype that fits your narrative, then customize.
## Cold Open — Environmental Cinema
**Signature:** Sticky cold open — environmental state (temperature, pressure, a reading) dissolves, landscape reveals, title layers in letter-by-letter. All driven by scroll within a 300vh container.
**Color arc:** cold → warm (ice blue → amber). **Particles:** white ice crystals, \`mix-blend-mode: screen\`, organic drift + bokeh. **HUD:** Telemetry (LAT/LON/ALT/BAT/TMP/SPD).
**Unique elements to consider:**
- Breath mist or steam CSS animation during state display
- Multilayer parallax interlude (3 layers at 0.05, 0.15, -0.05)
- Parallax window cutouts with \`scale(1.3)\` drift
- Mission timeline horizontal scroll
---
## Scan Wipe — The Mirror World
**Signature:** Scan wipe — real photo clips left-to-right via \`clip-path: inset(0 0 0 X%)\` revealing an abstraction beneath (wireframe, schematic, thermal). Glowing scan line tracks the edge. Text overlays fade in/out at scroll thresholds.
**Color arc:** warm → cool (golden → digital blue). **Particles:** colored scan points, \`mix-blend-mode: multiply\`. **HUD:** Metrics telemetry (LAT/LON/ALT/COV/RES/PTS).
**Unique elements to consider:**
- Scan wipe with glow line (2px, accent color, \`box-shadow\` glow)
- Split-screen before/after shuttle (scroll-position driven)
- Width-clip title reveal
- Numbered card grid chapter
---
## Countdown — The Clock
**Signature:** Persistent mission clock counting \`48:00:00 → 00:00:00\` with non-linear time compression. First 60% of scroll = slow buildup. Next 30% = fast processing. Last 10% = racing finale. Clock pulses at zero.
**Color arc:** all dark with intensifying glow. **Particles:** embers (rising) + ash (falling), both \`screen\`. **HUD:** Mission clock in accent mono with pulse animation.
**Unique elements to consider:**
- Non-linear time compression scroll mapping
- Horizontal scroll chapter for actors/agents/teams
- 2x2 grid of data products or outcomes
- Emotional closing section with parallax
---
## Spectral Peel — The Spectrum
**Signature:** Spectral layer peel — 5 stacked images of the same subject, each clipping upward via \`clip-path: inset(0 0 X% 0)\` as you scroll. Natural → filtered views (bands, seasons, phases, x-ray). Phase indicator updates per layer. Glow line at clip edge.
**Color arc:** neon accent intensifies mid-page, fades at bookends. **Particles:** mixed accent dots, \`screen\`. **HUD:** Sensor readout in accent mono.
**Unique elements to consider:**
- 5-layer peel with per-phase labels
- Formula or legend display in glowing accent mono
- Color ramp bar as progress legend
- Band/phase cards with wavelength-specific accents
- Progress bar uses thematic gradientWant a story built?
We designed this skill and we ship production stories with it. If you have a narrative that deserves the scroll treatment, start a conversation.