What's Working

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.

The Design Process

Six Steps

Every immersive story starts the same way. Not with code — with six design decisions that define everything the reader will experience.

01

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?

02

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.

03

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.

04

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.

05

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.

06

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.

The Catalogue

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.

S

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.

A

Memorable

Horizontal Scroll ChapterSection Color TransitionsSticky Background TransitionsCustom Cursor / Cursor TrailLetterbox ModeScroll-Driven CounterCharacter ScrambleHUD Flicker-InOversized Pull QuotesHorizontal Scroll Ticker
B

Polished

Reveal on EnterWidth-Clip Text RevealGradient Section BlendsProgress BarsTelemetry HUDParticle Systems
See It In Action

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.

01 S-Tier Jump to scroll position →

Word-scatter fog assembly

Opening confession assembles word-by-word from scattered positions, each rotating into place.

How it works Every word wrapped in a span with randomized x/y/rotation offsets. Scroll progress (0→1) interpolates each span back to its natural position.
02 S-Tier Jump to scroll position →

Horizontal chapter clip-path wipe

Four career eras slide in from the right, cut apart by letterbox interstitials.

How it works 900vh container. Each era sits stacked; scroll progress drives clip-path: inset(0 X% 0 0) revealing one era at a time. Interstitials use animated letterbox bars.
03 A-Tier Jump to scroll position →

Character scramble reveal

Era labels materialize as glitchy noise resolving to clean text.

How it works Text scramble fraction 0→1 replaces real characters with random glyphs; blur drops from 2px to 0 in lockstep.
04 S-Tier Jump to scroll position →

Parallax title with eye-spacing

Depth hero title blurs, scrambles, and its letterforms widen apart as you enter.

How it works Sticky section. Scroll progress drives letter-spacing + blur-radius + scale simultaneously.
05 A-Tier Jump to scroll position →

Mechanical counters

16 papers. 312 citations. h-index 8. Numbers step upward like a vintage scoreboard.

How it works IntersectionObserver fires once; rAF counter with discrete integer steps (not smooth lerp) gives the mechanical feel.
06 A-Tier Jump to scroll position →

Quantized film counter

681 films, ticking through 14 panels. The count snaps to whichever beat you’re scrolled into.

How it works Scroll progress quantized to 14 steps. Count = floor(progress * 681) locked to beat transitions.
07 S-Tier Jump to scroll position →

Playable scroll-triggered game

Canvas-based dodger game launches mid-page. Initials, leaderboard, the whole deal.

How it works Canvas game engine gated by scroll velocity; a real backend persists scores via /api. Chapter break = arcade.
08 S-Tier Jump to scroll position →

Typewriter paper zoom

A research paper scales from 0.2× to 1.0× while its body typewriter-reveals character by character.

How it works Scroll progress drives scale + character count. Key equation glows cyan when the typewriter reaches it.
09 S-Tier Jump to scroll position →

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.

How it works Each word is a span with its own transition-delay. At progress 0.70–0.88, flagged words get a 25px text-shadow + brightness boost.
10 A-Tier Jump to scroll position →

Contribution grid fill

GitHub-style commit grid fills from 12% to 85% as shipping velocity increases.

How it works Fixed grid of cells. Scroll progress drives the fill ratio; columns fill left→right for visual momentum.
The Architecture

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 Prompt

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-storytelling-skill.md
# 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 gradient

Want 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.