Scrollytelling is a narrative-driven web design paradigm that transforms the simple act of scrolling into an immersive storytelling experience. Originating in digital journalism – most notably the New York Times’ groundbreaking 2012 piece “Snow Fall: The Avalanche at Tunnel Creek” – scrollytelling weds long-form narrative with interactive visuals, parallax layering, and progressive content reveals that unfold in direct response to the user’s scroll position. The scroll itself becomes the primary input mechanism: as readers advance down the page, text fades in, charts animate, backgrounds shift at different speeds, and media elements slide into view, turning passive consumption into active participation.
At its core, scrollytelling is built on the principle that complex stories – whether investigative journalism, brand narratives, annual reports, or data essays – deserve more than a wall of text. Publications like The Pudding, Bloomberg, and the Guardian have elevated the form into a distinct design language characterized by generous white space, full-viewport sections, cinematic imagery, and carefully choreographed motion. Each scroll unit (often called a “step”) represents a discrete narrative beat: one idea, one visual, one moment of revelation. The rhythm of these steps controls pacing just as cuts and dissolves control a film.
Technically, scrollytelling relies on a stack of modern CSS and JavaScript patterns: position sticky for anchoring graphic panels while text scrolls past them, the Intersection Observer API (and libraries like Scrollama) for triggering step-based transitions, the newer CSS Scroll-Driven Animations specification for hardware-accelerated parallax and progress-linked keyframes, and semantic HTML structures that separate narrative steps from their visual counterparts. Accessibility and performance are first-class concerns – scroll-driven experiences must degrade gracefully, respect reduced-motion preferences, and remain readable without JavaScript.
The visual tone is editorial and cinematic: high-contrast type against immersive photography, muted palettes punctuated by data-visualization accents, and layouts that breathe with whitespace. Typography is bold and purposeful, often animated or sequentially revealed to control reading pace. The result is a web experience that feels closer to a documentary film or museum installation than a traditional webpage.
position: sticky) while explanatory text scrolls past it, anchoring the reader’s attentionprefers-reduced-motion setThe scrollytelling palette is editorial and cinematic. A near-black primary background sets a stage that recedes, letting content and data visuals command attention. Warm off-white text ensures readability without the harshness of pure white on pure black. Accent colors are borrowed from data-visualization conventions – blues for neutral information, coral and amber for emphasis, green for positive signals – applied sparingly so each splash of color carries narrative weight.
| Color Name | Hex | Role / Usage |
|---|---|---|
| Midnight | #0D1117 |
Primary background, immersive dark sections |
| Ink | #161B22 |
Card backgrounds, elevated dark surfaces |
| Slate | #21262D |
Secondary surfaces, navigation backdrop |
| Graphite | #30363D |
Borders, dividers, subtle outlines |
| Ash | #8B949E |
Secondary text, captions, metadata |
| Fog | #C9D1D9 |
Primary body text on dark backgrounds |
| Snow | #F0F6FC |
Headings, high-emphasis text on dark |
| Canvas | #FAFBFC |
Light-mode section backgrounds, pull-quotes |
| Signal Blue | #58A6FF |
Data visualization primary, links, interactive highlights |
| Deep Teal | #3FB8AF |
Secondary data color, success states, chart accents |
| Coral | #F97583 |
Alert accents, key data callouts, emphasis |
| Amber | #E3B341 |
Warning highlights, annotations, progress indicators |
| Lavender | #BC8CFF |
Tertiary data color, decorative accents |
| Moss | #56D364 |
Positive change indicators, upward trends |
| Burnt Sienna | #DA6D42 |
Warm accent, call-to-action on dark surfaces |
:root {
/* Backgrounds */
--scroll-bg-primary: #0d1117;
--scroll-bg-surface: #161b22;
--scroll-bg-elevated: #21262d;
--scroll-bg-light: #fafbfc;
/* Borders */
--scroll-border: #30363d;
/* Text */
--scroll-text-heading: #f0f6fc;
--scroll-text-body: #c9d1d9;
--scroll-text-muted: #8b949e;
--scroll-text-dark: #161b22;
/* Accents */
--scroll-accent-blue: #58a6ff;
--scroll-accent-teal: #3fb8af;
--scroll-accent-coral: #f97583;
--scroll-accent-amber: #e3b341;
--scroll-accent-lavender: #bc8cff;
--scroll-accent-green: #56d364;
--scroll-accent-sienna: #da6d42;
/* Layout tokens */
--scroll-section-padding: clamp(4rem, 10vh, 8rem);
--scroll-content-width: 680px;
--scroll-wide-width: 1120px;
--scroll-step-gap: 80vh;
}
Scrollytelling typography is:
| Font | Style | Best For |
|---|---|---|
| Playfair Display | High-contrast serif, editorial | Chapter titles, hero headlines, dramatic display |
| Lora | Contemporary serif, readable at length | Long-form body text, article prose |
| Source Serif 4 | Sturdy transitional serif | Body text, serious editorial tone |
| Inter | Screen-optimized sans-serif | UI text, captions, navigation, data labels |
| Space Grotesk | Monospace-influenced proportional | Data callouts, chart labels, technical annotations |
| JetBrains Mono | True monospace, ligatures | Code snippets, tabular numbers, counters |
| DM Sans | Clean geometric sans-serif | Section subheadings, secondary display text |
| Fraunces | Soft serif with optical size axis | Display headings, expressive chapter titles |
| Heading Font | Body Font | Character |
|---|---|---|
| Playfair Display (700) | Inter (400) | Classic editorial, NYT-inspired gravitas |
| Fraunces (700) | Source Serif 4 (400) | Warm, expressive, long-form narrative |
| DM Sans (700) | Inter (400) | Modern, data-forward, clean tech storytelling |
| Playfair Display (700) | Lora (400) | All-serif, literary, immersive deep reads |
| Space Grotesk (600) | Inter (400) | Technical, analytical, data journalism |
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
h1, h2, h3, h4, h5, h6 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 700;
color: var(--scroll-text-heading);
line-height: 1.15;
letter-spacing: -0.01em;
}
.scroll-display {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2.5rem, 6vw, 5rem);
font-weight: 900;
letter-spacing: -0.02em;
line-height: 1.05;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.75;
color: var(--scroll-text-body);
}
.scroll-prose {
max-width: var(--scroll-content-width);
margin: 0 auto;
font-size: 1.2rem;
line-height: 1.8;
}
.scroll-caption {
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 0.85rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--scroll-text-muted);
}
.scroll-data {
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
font-size: 2.5rem;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
color: var(--scroll-accent-blue);
}
.scroll-pullquote {
font-family: 'Playfair Display', Georgia, serif;
font-style: italic;
font-size: 1.8rem;
line-height: 1.4;
color: var(--scroll-accent-amber);
border-left: 3px solid var(--scroll-accent-amber);
padding-left: 1.5rem;
margin: 3rem 0;
}
@media (prefers-reduced-motion: no-preference) so that reduced-motion users see a clean, static long-form articleThe foundational scrollytelling layout: a graphic sticks in place while narrative text steps scroll over or beside it.
.scroll-section {
position: relative;
display: flex;
gap: 2rem;
}
.scroll-graphic {
position: sticky;
top: 0;
height: 100vh;
width: 55%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.scroll-graphic__inner {
width: 100%;
max-width: 600px;
transition: opacity 0.6s ease, transform 0.6s ease;
}
.scroll-steps {
width: 45%;
padding: var(--scroll-section-padding) 0;
}
.scroll-step {
min-height: var(--scroll-step-gap);
display: flex;
align-items: center;
padding: 2rem;
opacity: 0.2;
transition: opacity 0.5s ease;
}
.scroll-step.is-active {
opacity: 1;
}
@media (max-width: 768px) {
.scroll-section {
flex-direction: column;
}
.scroll-graphic {
position: sticky;
width: 100%;
height: 50vh;
}
.scroll-steps {
width: 100%;
padding: 1rem;
}
.scroll-step {
min-height: 60vh;
background: linear-gradient(
to bottom,
rgba(13, 17, 23, 0.95),
rgba(13, 17, 23, 0.7)
);
border-radius: 12px;
}
}
A cinematic, full-viewport opening that sets the narrative tone with layered parallax and a slow content reveal.
.scroll-hero {
position: relative;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--scroll-bg-primary);
}
.scroll-hero__bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: brightness(0.4);
transform: scale(1.1);
animation: heroZoom 20s ease-out forwards;
}
@keyframes heroZoom {
to { transform: scale(1); }
}
.scroll-hero__content {
position: relative;
z-index: 10;
text-align: center;
max-width: 800px;
padding: 2rem;
}
.scroll-hero__title {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2.8rem, 7vw, 6rem);
font-weight: 900;
color: var(--scroll-text-heading);
line-height: 1.05;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
opacity: 0;
transform: translateY(30px);
animation: heroReveal 1.2s ease-out 0.3s forwards;
}
.scroll-hero__subtitle {
font-family: 'Inter', sans-serif;
font-size: clamp(1rem, 2vw, 1.35rem);
color: var(--scroll-text-muted);
line-height: 1.7;
max-width: 540px;
margin: 0 auto 2rem;
opacity: 0;
animation: heroReveal 1.2s ease-out 0.6s forwards;
}
.scroll-hero__cue {
opacity: 0;
animation: heroReveal 1s ease-out 1.2s forwards;
}
.scroll-hero__cue span {
display: block;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--scroll-text-muted);
margin-bottom: 0.5rem;
}
.scroll-hero__cue::after {
content: '';
display: block;
width: 1px;
height: 60px;
background: var(--scroll-text-muted);
margin: 0 auto;
animation: scrollPulse 2s ease-in-out infinite;
}
@keyframes heroReveal {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scrollPulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 1; transform: scaleY(1); }
}
Narrative cards that appear as the user scrolls, used for key statistics, quotes, or sidebar context.
.scroll-card {
background: var(--scroll-bg-surface);
border: 1px solid var(--scroll-border);
border-radius: 12px;
padding: 2rem;
max-width: 480px;
opacity: 0;
transform: translateY(40px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.scroll-card.is-visible {
opacity: 1;
transform: translateY(0);
}
.scroll-card__eyebrow {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--scroll-accent-blue);
margin-bottom: 0.75rem;
}
.scroll-card__title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.4rem;
font-weight: 700;
color: var(--scroll-text-heading);
margin-bottom: 0.75rem;
line-height: 1.3;
}
.scroll-card__body {
font-size: 1rem;
line-height: 1.7;
color: var(--scroll-text-body);
}
.scroll-card__stat {
font-family: 'JetBrains Mono', monospace;
font-size: 2.5rem;
font-weight: 500;
color: var(--scroll-accent-teal);
line-height: 1;
margin-bottom: 0.5rem;
}
.scroll-card--highlight {
border-color: var(--scroll-accent-blue);
border-width: 2px;
box-shadow: 0 0 30px rgba(88, 166, 255, 0.08);
}
.scroll-card--dark {
background: var(--scroll-bg-primary);
border-color: transparent;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
}
Action buttons for navigation between chapters, calls to action, and interactive data controls.
.scroll-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 32px;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0.02em;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.scroll-button--primary {
background: var(--scroll-accent-blue);
color: var(--scroll-bg-primary);
}
.scroll-button--primary:hover {
background: #79b8ff;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(88, 166, 255, 0.25);
}
.scroll-button--ghost {
background: transparent;
color: var(--scroll-text-heading);
border: 1px solid var(--scroll-border);
}
.scroll-button--ghost:hover {
background: var(--scroll-bg-elevated);
border-color: var(--scroll-text-muted);
}
.scroll-button--cta {
background: var(--scroll-accent-sienna);
color: #ffffff;
font-size: 1.05rem;
padding: 16px 40px;
border-radius: 10px;
}
.scroll-button--cta:hover {
background: #e5885e;
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(218, 109, 66, 0.3);
}
.scroll-button__arrow {
transition: transform 0.3s ease;
}
.scroll-button:hover .scroll-button__arrow {
transform: translateX(4px);
}
A minimal, semi-transparent nav that stays out of the story’s way but provides chapter-based wayfinding.
.scroll-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: rgba(13, 17, 23, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
transform: translateY(-100%);
transition: transform 0.4s ease;
}
.scroll-nav.is-visible {
transform: translateY(0);
}
.scroll-nav__logo {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 700;
font-size: 1.1rem;
color: var(--scroll-text-heading);
text-decoration: none;
}
.scroll-nav__chapters {
display: flex;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
.scroll-nav__chapter {
font-family: 'Inter', sans-serif;
font-size: 0.8rem;
font-weight: 500;
color: var(--scroll-text-muted);
text-decoration: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
transition: color 0.3s ease, background 0.3s ease;
}
.scroll-nav__chapter:hover {
color: var(--scroll-text-heading);
background: var(--scroll-bg-elevated);
}
.scroll-nav__chapter.is-active {
color: var(--scroll-accent-blue);
background: rgba(88, 166, 255, 0.1);
}
.scroll-nav__progress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: var(--scroll-accent-blue);
transition: width 0.2s ease-out;
}
@media (max-width: 768px) {
.scroll-nav__chapters { display: none; }
.scroll-nav { padding: 0.75rem 1.25rem; }
}
Multi-speed parallax using CSS scroll-driven animations for buttery-smooth depth.
.scroll-parallax {
position: relative;
height: 100vh;
overflow: hidden;
}
.scroll-parallax__layer {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
will-change: transform;
}
.scroll-parallax__layer--back {
transform: translateY(0);
animation: parallaxBack linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes parallaxBack {
from { transform: translateY(-15%); }
to { transform: translateY(15%); }
}
.scroll-parallax__layer--mid {
animation: parallaxMid linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes parallaxMid {
from { transform: translateY(-8%); }
to { transform: translateY(8%); }
}
.scroll-parallax__layer--front {
animation: parallaxFront linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes parallaxFront {
from { transform: translateY(-3%); }
to { transform: translateY(3%); }
}
/* Fallback for browsers without scroll-driven animations */
@supports not (animation-timeline: view()) {
.scroll-parallax__layer--back {
animation: none;
}
.scroll-parallax__layer--mid {
animation: none;
}
.scroll-parallax__layer--front {
animation: none;
}
}
Reusable entrance animations for elements that appear as the user scrolls into their viewport region.
.scroll-reveal {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.scroll-reveal--left {
transform: translateX(-60px);
}
.scroll-reveal--left.is-visible {
transform: translateX(0);
}
.scroll-reveal--right {
transform: translateX(60px);
}
.scroll-reveal--right.is-visible {
transform: translateX(0);
}
.scroll-reveal--scale {
transform: scale(0.9);
}
.scroll-reveal--scale.is-visible {
transform: scale(1);
}
.scroll-reveal--fade {
transform: none;
}
.scroll-reveal--fade.is-visible {
opacity: 1;
}
/* Stagger children */
.scroll-reveal-group .scroll-reveal:nth-child(1) { transition-delay: 0s; }
.scroll-reveal-group .scroll-reveal:nth-child(2) { transition-delay: 0.1s; }
.scroll-reveal-group .scroll-reveal:nth-child(3) { transition-delay: 0.2s; }
.scroll-reveal-group .scroll-reveal:nth-child(4) { transition-delay: 0.3s; }
.scroll-reveal-group .scroll-reveal:nth-child(5) { transition-delay: 0.4s; }
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
opacity: 1;
transform: none;
transition: none;
}
}
Scroll-triggered number counters for presenting statistics and key figures within the narrative.
.scroll-counter {
font-family: 'JetBrains Mono', monospace;
font-size: clamp(2.5rem, 5vw, 4.5rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--scroll-accent-blue);
line-height: 1;
white-space: nowrap;
}
.scroll-counter__label {
display: block;
font-family: 'Inter', sans-serif;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--scroll-text-muted);
margin-top: 0.75rem;
}
.scroll-counter-row {
display: flex;
gap: 4rem;
justify-content: center;
padding: 4rem 2rem;
}
.scroll-counter-row__item {
text-align: center;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.scroll-counter-row__item.is-visible {
opacity: 1;
transform: translateY(0);
}
.scroll-counter-row__item:nth-child(2) { transition-delay: 0.15s; }
.scroll-counter-row__item:nth-child(3) { transition-delay: 0.3s; }
.scroll-counter-row__item:nth-child(4) { transition-delay: 0.45s; }
@media (max-width: 640px) {
.scroll-counter-row {
flex-direction: column;
gap: 2.5rem;
align-items: center;
}
}
@media (prefers-reduced-motion: no-preference) and ensure the no-JS experience is a clean, readable long-form articleloading="lazy", WebP/AVIF formats, and <picture> elements; a scrollytelling page can easily exceed 20MB without careful asset management| Aesthetic | Relationship |
|---|---|
| Parallax Design | Scrollytelling’s foundational visual technique; multi-speed layer movement creates depth and immersion |
| Editorial / Magazine Layout | Shares the narrow-column, high-contrast typographic sensibility and cinematic image treatment |
| Data Visualization | Scrollytelling is the preferred delivery mechanism for interactive data stories and annotated charts |
| Brutalist Web Design | Both reject conventional page templates; brutalism strips back, scrollytelling layers up |
| Cinematic Web | Full-viewport sections, dramatic imagery, and ambient motion overlap heavily with cinematic web aesthetics |
| Minimalism | Scrollytelling’s generous whitespace and single-focus sections share minimalism’s restraint philosophy |
| Dark Mode UI | Many scrollytelling pieces use dark backgrounds for cinematic atmosphere and to make data visuals pop |
| Motion Design | Scroll-linked animation is a subset of motion design; timing, easing, and choreography principles apply directly |
| Longform / Immersive Journalism | The editorial genre that birthed scrollytelling; Snow Fall, The Pudding, and Bloomberg Businessweek are canonical examples |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrollytelling -- Story Title</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ============================================
RESET & BASE
============================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Backgrounds */
--scroll-bg-primary: #0d1117;
--scroll-bg-surface: #161b22;
--scroll-bg-elevated: #21262d;
--scroll-bg-light: #fafbfc;
/* Borders */
--scroll-border: #30363d;
/* Text */
--scroll-text-heading: #f0f6fc;
--scroll-text-body: #c9d1d9;
--scroll-text-muted: #8b949e;
--scroll-text-dark: #161b22;
/* Accents */
--scroll-accent-blue: #58a6ff;
--scroll-accent-teal: #3fb8af;
--scroll-accent-coral: #f97583;
--scroll-accent-amber: #e3b341;
--scroll-accent-lavender: #bc8cff;
--scroll-accent-green: #56d364;
--scroll-accent-sienna: #da6d42;
/* Layout */
--scroll-content-width: 680px;
--scroll-wide-width: 1120px;
--scroll-step-gap: 80vh;
--scroll-section-padding: clamp(4rem, 10vh, 8rem);
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 1.125rem;
line-height: 1.75;
color: var(--scroll-text-body);
background: var(--scroll-bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 700;
color: var(--scroll-text-heading);
line-height: 1.15;
}
a {
color: var(--scroll-accent-blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
/* ============================================
NAVIGATION
============================================ */
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: rgba(13, 17, 23, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
transform: translateY(-100%);
transition: transform 0.4s ease;
}
.nav.is-visible {
transform: translateY(0);
}
.nav__logo {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 700;
font-size: 1.1rem;
color: var(--scroll-text-heading);
text-decoration: none;
}
.nav__links {
display: flex;
gap: 0.25rem;
list-style: none;
}
.nav__link {
font-size: 0.8rem;
font-weight: 500;
color: var(--scroll-text-muted);
text-decoration: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
transition: color 0.3s ease, background 0.3s ease;
}
.nav__link:hover {
color: var(--scroll-text-heading);
background: var(--scroll-bg-elevated);
text-decoration: none;
}
.nav__link.is-active {
color: var(--scroll-accent-blue);
background: rgba(88, 166, 255, 0.1);
}
.nav__progress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 0%;
background: var(--scroll-accent-blue);
transition: width 0.15s ease-out;
}
@media (max-width: 768px) {
.nav__links { display: none; }
}
/* ============================================
HERO
============================================ */
.hero {
position: relative;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero__bg {
position: absolute;
inset: -10%;
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 70%, #0d1117 100%);
animation: heroDrift 20s ease-in-out infinite alternate;
}
@keyframes heroDrift {
0% { transform: scale(1) translate(0, 0); }
100% { transform: scale(1.05) translate(-1%, -1%); }
}
.hero__particles {
position: absolute;
inset: 0;
overflow: hidden;
}
.hero__particle {
position: absolute;
width: 4px;
height: 4px;
background: var(--scroll-accent-blue);
border-radius: 50%;
opacity: 0;
animation: particleFloat 8s ease-in-out infinite;
}
.hero__particle:nth-child(1) { left: 15%; top: 20%; animation-delay: 0s; }
.hero__particle:nth-child(2) { left: 70%; top: 60%; animation-delay: 2s; }
.hero__particle:nth-child(3) { left: 40%; top: 80%; animation-delay: 4s; animation-duration: 10s; }
.hero__particle:nth-child(4) { left: 85%; top: 30%; animation-delay: 1s; animation-duration: 7s; }
.hero__particle:nth-child(5) { left: 25%; top: 55%; animation-delay: 3s; animation-duration: 9s; }
.hero__particle:nth-child(6) { left: 60%; top: 15%; animation-delay: 5s; }
@keyframes particleFloat {
0%, 100% { opacity: 0; transform: translateY(0) scale(1); }
20% { opacity: 0.6; }
50% { opacity: 0.3; transform: translateY(-40px) scale(1.5); }
80% { opacity: 0.5; }
}
.hero__content {
position: relative;
z-index: 10;
text-align: center;
max-width: 800px;
padding: 2rem;
}
.hero__eyebrow {
font-family: 'Inter', sans-serif;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--scroll-accent-blue);
margin-bottom: 1.5rem;
opacity: 0;
animation: fadeUp 1s ease-out 0.2s forwards;
}
.hero__title {
font-size: clamp(2.8rem, 7vw, 5.5rem);
font-weight: 900;
line-height: 1.05;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
opacity: 0;
animation: fadeUp 1.2s ease-out 0.4s forwards;
}
.hero__subtitle {
font-size: clamp(1rem, 2vw, 1.3rem);
color: var(--scroll-text-muted);
max-width: 520px;
margin: 0 auto 3rem;
opacity: 0;
animation: fadeUp 1.2s ease-out 0.7s forwards;
}
.hero__scroll-cue {
opacity: 0;
animation: fadeUp 1s ease-out 1.2s forwards;
text-align: center;
}
.hero__scroll-cue span {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--scroll-text-muted);
margin-bottom: 0.75rem;
}
.hero__scroll-line {
display: block;
width: 1px;
height: 50px;
background: var(--scroll-text-muted);
margin: 0 auto;
animation: scrollPulse 2s ease-in-out infinite;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scrollPulse {
0%, 100% { opacity: 0.2; transform: scaleY(0.5); transform-origin: top; }
50% { opacity: 1; transform: scaleY(1); }
}
/* ============================================
CONTENT SECTIONS
============================================ */
.section {
padding: var(--scroll-section-padding) 2rem;
}
.section--dark {
background: var(--scroll-bg-primary);
}
.section--surface {
background: var(--scroll-bg-surface);
}
.section--light {
background: var(--scroll-bg-light);
color: var(--scroll-text-dark);
}
.section--light h2,
.section--light h3 {
color: var(--scroll-text-dark);
}
.prose {
max-width: var(--scroll-content-width);
margin: 0 auto;
}
.prose p {
margin-bottom: 1.5rem;
}
.wide {
max-width: var(--scroll-wide-width);
margin: 0 auto;
}
.section__eyebrow {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--scroll-accent-blue);
margin-bottom: 1rem;
}
.section__title {
font-size: clamp(2rem, 4vw, 3.5rem);
margin-bottom: 1.5rem;
}
.section__subtitle {
font-size: 1.15rem;
color: var(--scroll-text-muted);
max-width: 540px;
margin-bottom: 3rem;
}
/* ============================================
STICKY SCROLLYTELLING SECTION
============================================ */
.scrolly {
position: relative;
display: flex;
gap: 2rem;
max-width: var(--scroll-wide-width);
margin: 0 auto;
padding: 0 2rem;
}
.scrolly__graphic {
position: sticky;
top: 0;
height: 100vh;
width: 55%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.scrolly__graphic-inner {
width: 100%;
max-width: 500px;
aspect-ratio: 4 / 3;
background: var(--scroll-bg-surface);
border: 1px solid var(--scroll-border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.scrolly__graphic-label {
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
color: var(--scroll-text-muted);
}
.scrolly__steps {
width: 45%;
padding: var(--scroll-section-padding) 0;
}
.scrolly__step {
min-height: var(--scroll-step-gap);
display: flex;
align-items: center;
padding: 2rem;
opacity: 0.15;
transition: opacity 0.6s ease;
}
.scrolly__step.is-active {
opacity: 1;
}
.scrolly__step-content {
background: var(--scroll-bg-surface);
border: 1px solid var(--scroll-border);
border-radius: 12px;
padding: 2rem;
}
.scrolly__step-number {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--scroll-accent-blue);
margin-bottom: 0.5rem;
}
.scrolly__step-title {
font-size: 1.3rem;
margin-bottom: 0.75rem;
}
.scrolly__step-text {
font-size: 1rem;
line-height: 1.7;
color: var(--scroll-text-body);
}
@media (max-width: 768px) {
.scrolly {
flex-direction: column;
padding: 0 1rem;
}
.scrolly__graphic {
position: sticky;
width: 100%;
height: 45vh;
}
.scrolly__steps {
width: 100%;
position: relative;
z-index: 10;
margin-top: -10vh;
}
.scrolly__step {
min-height: 70vh;
}
.scrolly__step-content {
background: rgba(22, 27, 34, 0.92);
backdrop-filter: blur(8px);
}
}
/* ============================================
COUNTER ROW
============================================ */
.counters {
display: flex;
gap: 3rem;
justify-content: center;
flex-wrap: wrap;
padding: 2rem 0;
}
.counter {
text-align: center;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.counter.is-visible {
opacity: 1;
transform: translateY(0);
}
.counter:nth-child(2) { transition-delay: 0.15s; }
.counter:nth-child(3) { transition-delay: 0.3s; }
.counter__value {
font-family: 'JetBrains Mono', monospace;
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--scroll-accent-blue);
line-height: 1;
}
.counter__value--teal { color: var(--scroll-accent-teal); }
.counter__value--coral { color: var(--scroll-accent-coral); }
.counter__value--amber { color: var(--scroll-accent-amber); }
.counter__label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--scroll-text-muted);
margin-top: 0.5rem;
}
/* ============================================
CARDS
============================================ */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.card {
background: var(--scroll-bg-surface);
border: 1px solid var(--scroll-border);
border-radius: 12px;
padding: 2rem;
opacity: 0;
transform: translateY(30px);
transition: opacity 0.7s ease, transform 0.7s ease, border-color 0.3s ease;
}
.card.is-visible {
opacity: 1;
transform: translateY(0);
}
.card:nth-child(2) { transition-delay: 0.12s; }
.card:nth-child(3) { transition-delay: 0.24s; }
.card:hover {
border-color: var(--scroll-accent-blue);
}
.card__icon {
font-size: 1.8rem;
margin-bottom: 1rem;
}
.card__title {
font-family: 'Inter', sans-serif;
font-size: 1.1rem;
font-weight: 600;
color: var(--scroll-text-heading);
margin-bottom: 0.5rem;
}
.card__text {
font-size: 0.95rem;
line-height: 1.65;
color: var(--scroll-text-muted);
}
/* ============================================
PULLQUOTE
============================================ */
.pullquote {
font-family: 'Playfair Display', Georgia, serif;
font-style: italic;
font-size: clamp(1.4rem, 3vw, 1.8rem);
line-height: 1.5;
color: var(--scroll-accent-amber);
border-left: 3px solid var(--scroll-accent-amber);
padding-left: 1.5rem;
margin: 3rem 0;
max-width: var(--scroll-content-width);
margin-left: auto;
margin-right: auto;
}
.pullquote__cite {
display: block;
font-family: 'Inter', sans-serif;
font-style: normal;
font-size: 0.85rem;
color: var(--scroll-text-muted);
margin-top: 1rem;
}
/* ============================================
BUTTONS
============================================ */
.btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 14px 32px;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 0.95rem;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
}
.btn--primary {
background: var(--scroll-accent-blue);
color: var(--scroll-bg-primary);
}
.btn--primary:hover {
background: #79b8ff;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(88, 166, 255, 0.25);
text-decoration: none;
}
.btn--ghost {
background: transparent;
color: var(--scroll-text-heading);
border: 1px solid var(--scroll-border);
}
.btn--ghost:hover {
background: var(--scroll-bg-elevated);
border-color: var(--scroll-text-muted);
text-decoration: none;
}
.btn__arrow {
transition: transform 0.3s ease;
}
.btn:hover .btn__arrow {
transform: translateX(4px);
}
/* ============================================
FULL-BLEED IMAGE
============================================ */
.full-bleed {
width: 100vw;
margin-left: calc(-50vw + 50%);
position: relative;
overflow: hidden;
}
.full-bleed__img {
width: 100%;
height: 70vh;
object-fit: cover;
display: block;
}
.full-bleed__caption {
position: absolute;
bottom: 2rem;
left: 2rem;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
padding: 0.4rem 0.8rem;
border-radius: 4px;
}
/* ============================================
FOOTER
============================================ */
.footer {
padding: 4rem 2rem;
text-align: center;
background: var(--scroll-bg-surface);
border-top: 1px solid var(--scroll-border);
}
.footer__title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.footer__text {
color: var(--scroll-text-muted);
max-width: 480px;
margin: 0 auto 2rem;
}
.footer__links {
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--scroll-border);
}
.footer__link {
font-size: 0.85rem;
color: var(--scroll-text-muted);
}
.footer__link:hover {
color: var(--scroll-accent-blue);
}
/* ============================================
SCROLL REVEAL
============================================ */
.reveal {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* ============================================
REDUCED MOTION
============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.reveal,
.card,
.counter,
.scrolly__step {
opacity: 1;
transform: none;
}
html {
scroll-behavior: auto;
}
}
</style>
</head>
<body>
<!-- ============ NAVIGATION ============ -->
<nav class="nav" id="nav" aria-label="Story navigation">
<a href="#" class="nav__logo">The Story</a>
<ul class="nav__links">
<li><a href="#opening" class="nav__link is-active" data-chapter="opening">Opening</a></li>
<li><a href="#data" class="nav__link" data-chapter="data">The Data</a></li>
<li><a href="#deep-dive" class="nav__link" data-chapter="deep-dive">Deep Dive</a></li>
<li><a href="#impact" class="nav__link" data-chapter="impact">Impact</a></li>
</ul>
<div class="nav__progress" id="navProgress"></div>
</nav>
<!-- ============ HERO ============ -->
<header class="hero" id="opening">
<div class="hero__bg"></div>
<div class="hero__particles" aria-hidden="true">
<div class="hero__particle"></div>
<div class="hero__particle"></div>
<div class="hero__particle"></div>
<div class="hero__particle"></div>
<div class="hero__particle"></div>
<div class="hero__particle"></div>
</div>
<div class="hero__content">
<p class="hero__eyebrow">A Visual Investigation</p>
<h1 class="hero__title">The Story Unfolds<br>As You Scroll</h1>
<p class="hero__subtitle">
An immersive narrative experience that transforms data into understanding,
one scroll at a time.
</p>
<div class="hero__scroll-cue">
<span>Scroll to explore</span>
<span class="hero__scroll-line" aria-hidden="true"></span>
</div>
</div>
</header>
<!-- ============ INTRODUCTION ============ -->
<section class="section section--dark">
<div class="prose reveal">
<p class="section__eyebrow">Chapter 1</p>
<h2 class="section__title">Setting the Scene</h2>
<p>
Every great story starts with context. This opening section establishes
the who, what, and why -- grounding the reader before the narrative
accelerates. The narrow column and generous line spacing encourage
focused, sustained reading.
</p>
<p>
Scrollytelling works because it puts the reader in control. Each
paragraph, each visual, each data point appears precisely when the
reader is ready for it. There is no autoplay, no timer, no rush.
Only the steady downward pull of curiosity.
</p>
</div>
</section>
<!-- ============ DATA COUNTERS ============ -->
<section class="section section--surface" id="data">
<div class="wide">
<div class="prose reveal">
<p class="section__eyebrow">Chapter 2</p>
<h2 class="section__title">By the Numbers</h2>
<p class="section__subtitle">
Key figures that frame the scale of the story.
</p>
</div>
<div class="counters">
<div class="counter">
<span class="counter__value" data-target="2847">0</span>
<span class="counter__label">Data Points Analyzed</span>
</div>
<div class="counter">
<span class="counter__value counter__value--teal" data-target="142">0</span>
<span class="counter__label">Sources Verified</span>
</div>
<div class="counter">
<span class="counter__value counter__value--coral" data-target="37">0</span>
<span class="counter__label">Months of Research</span>
</div>
</div>
</div>
</section>
<!-- ============ PULLQUOTE ============ -->
<section class="section section--dark">
<div class="pullquote reveal">
"The best scrollytelling doesn't just present information -- it
creates a journey where each revelation builds on the last, pulling
the reader deeper into understanding."
<cite class="pullquote__cite">-- Senior Data Editor, The Pudding</cite>
</div>
</section>
<!-- ============ STICKY SCROLLYTELLING ============ -->
<section class="section section--dark" id="deep-dive">
<div class="prose reveal" style="margin-bottom: 4rem;">
<p class="section__eyebrow">Chapter 3</p>
<h2 class="section__title">The Deep Dive</h2>
<p class="section__subtitle">
Scroll through each step to see the data transform.
</p>
</div>
<div class="scrolly">
<div class="scrolly__graphic">
<div class="scrolly__graphic-inner" id="stickyGraphic">
<span class="scrolly__graphic-label">Visualization Area</span>
</div>
</div>
<div class="scrolly__steps">
<div class="scrolly__step is-active" data-step="1">
<div class="scrolly__step-content">
<p class="scrolly__step-number">Step 01</p>
<h3 class="scrolly__step-title">The Baseline</h3>
<p class="scrolly__step-text">
We begin with the raw data -- unfiltered and unprocessed.
The visualization shows the full dataset as an undifferentiated
scatter of points.
</p>
</div>
</div>
<div class="scrolly__step" data-step="2">
<div class="scrolly__step-content">
<p class="scrolly__step-number">Step 02</p>
<h3 class="scrolly__step-title">Patterns Emerge</h3>
<p class="scrolly__step-text">
As we apply the first filter, clusters begin to form. The data
is not random -- there are clear groupings that correspond to
the three primary categories identified in our research.
</p>
</div>
</div>
<div class="scrolly__step" data-step="3">
<div class="scrolly__step-content">
<p class="scrolly__step-number">Step 03</p>
<h3 class="scrolly__step-title">The Outliers</h3>
<p class="scrolly__step-text">
Now we highlight the anomalies -- data points that defy the
established patterns. These outliers are where the real story
lives, revealing unexpected connections.
</p>
</div>
</div>
<div class="scrolly__step" data-step="4">
<div class="scrolly__step-content">
<p class="scrolly__step-number">Step 04</p>
<h3 class="scrolly__step-title">The Conclusion</h3>
<p class="scrolly__step-text">
With all layers visible, the complete picture comes into focus.
What seemed like noise was signal all along -- the data tells
a story of transformation hiding in plain sight.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- ============ CARDS ============ -->
<section class="section section--surface" id="impact">
<div class="wide">
<div class="prose reveal">
<p class="section__eyebrow">Chapter 4</p>
<h2 class="section__title">Impact & Implications</h2>
<p class="section__subtitle">
Three key takeaways from the investigation.
</p>
</div>
<div class="cards">
<div class="card">
<div class="card__icon" aria-hidden="true">◆</div>
<h3 class="card__title">Finding One</h3>
<p class="card__text">
The primary discovery challenges long-held assumptions about the
dataset. When viewed through the lens of temporal analysis, the
trend reverses entirely.
</p>
</div>
<div class="card">
<div class="card__icon" aria-hidden="true">◇</div>
<h3 class="card__title">Finding Two</h3>
<p class="card__text">
Cross-referencing with external data reveals a correlation that
was previously invisible. This connection opens an entirely new
avenue for further research.
</p>
</div>
<div class="card">
<div class="card__icon" aria-hidden="true">◈</div>
<h3 class="card__title">Finding Three</h3>
<p class="card__text">
The implications extend well beyond the original scope. Policy
decisions, industry practices, and public understanding all
stand to be reshaped by these results.
</p>
</div>
</div>
</div>
</section>
<!-- ============ CLOSING ============ -->
<section class="section section--dark">
<div class="prose reveal" style="text-align: center;">
<h2 class="section__title">The Scroll Continues</h2>
<p style="margin-bottom: 2rem;">
This story is ongoing. As new data emerges, we will update this
investigation. Bookmark this page and return for the next chapter.
</p>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="#" class="btn btn--primary">
Share This Story <span class="btn__arrow" aria-hidden="true">→</span>
</a>
<a href="#" class="btn btn--ghost">View Methodology</a>
</div>
</div>
</section>
<!-- ============ FOOTER ============ -->
<footer class="footer">
<h2 class="footer__title">Scrollytelling Template</h2>
<p class="footer__text">
Built with semantic HTML, CSS custom properties, IntersectionObserver,
and a respect for reduced-motion preferences.
</p>
<div class="footer__links">
<a href="#" class="footer__link">Credits</a>
<a href="#" class="footer__link">Methodology</a>
<a href="#" class="footer__link">Data Sources</a>
<a href="#" class="footer__link">License</a>
</div>
</footer>
<!-- ============ JAVASCRIPT ============ -->
<script>
(function () {
'use strict';
/* ------ NAV: show after scrolling past hero ------ */
const nav = document.getElementById('nav');
const heroObserver = new IntersectionObserver(
([entry]) => {
nav.classList.toggle('is-visible', !entry.isIntersecting);
},
{ threshold: 0 }
);
heroObserver.observe(document.querySelector('.hero'));
/* ------ NAV: progress bar ------ */
const progressBar = document.getElementById('navProgress');
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
const total = document.documentElement.scrollHeight - window.innerHeight;
const pct = Math.min((scrolled / total) * 100, 100);
progressBar.style.width = pct + '%';
}, { passive: true });
/* ------ REVEAL: fade in elements on scroll ------ */
const revealEls = document.querySelectorAll('.reveal, .card, .counter');
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
revealObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.15 }
);
revealEls.forEach((el) => revealObserver.observe(el));
/* ------ COUNTERS: animate numbers ------ */
function animateCounter(el) {
const target = parseInt(el.getAttribute('data-target'), 10);
const duration = 1800;
const start = performance.now();
function tick(now) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.floor(eased * target).toLocaleString();
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const counterEls = document.querySelectorAll('.counter__value[data-target]');
const counterObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animateCounter(entry.target);
counterObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.5 }
);
counterEls.forEach((el) => counterObserver.observe(el));
/* ------ SCROLLY: step activation ------ */
const steps = document.querySelectorAll('.scrolly__step');
const graphic = document.getElementById('stickyGraphic');
const graphicColors = [
'var(--scroll-bg-surface)',
'rgba(88,166,255,0.08)',
'rgba(249,117,131,0.08)',
'rgba(63,184,175,0.12)'
];
const graphicLabels = [
'Raw Data',
'Clustered View',
'Outliers Highlighted',
'Full Picture'
];
const stepObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
steps.forEach((s) => s.classList.remove('is-active'));
entry.target.classList.add('is-active');
const idx = parseInt(entry.target.getAttribute('data-step'), 10) - 1;
if (graphic) {
graphic.style.background = graphicColors[idx] || graphicColors[0];
graphic.querySelector('.scrolly__graphic-label').textContent = graphicLabels[idx] || '';
}
}
});
},
{ rootMargin: '-40% 0px -40% 0px', threshold: 0 }
);
steps.forEach((step) => stepObserver.observe(step));
/* ------ NAV: active chapter links ------ */
const chapters = document.querySelectorAll('[id]');
const navLinks = document.querySelectorAll('.nav__link[data-chapter]');
const chapterObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('id');
navLinks.forEach((link) => {
link.classList.toggle('is-active', link.getAttribute('data-chapter') === id);
});
}
});
},
{ rootMargin: '-30% 0px -60% 0px', threshold: 0 }
);
chapters.forEach((ch) => {
if (ch.getAttribute('id')) chapterObserver.observe(ch);
});
})();
</script>
</body>
</html>
animation-timeline: scroll() and animation-timeline: view() can handle parallax and progress-linked animations without any JavaScript at all, though browser support should be verifiedmin-height: 80vh on each step ensures consistent pacing regardless of screen size; pixel values break at different resolutionsloading="lazy" on images, defer non-critical scripts, and consider using content-visibility: auto on off-screen sections to reduce initial paint cost on media-heavy pagesprefers-reduced-motion: reduce enabled – in Chrome DevTools (Rendering tab > Emulate CSS media feature), verify that the page degrades to a clean, fully readable static article with no missing contentwill-change: transform on parallax layers helps but overuse hurts memory