Narrative Scroll Controller
A narrative shell that changes rhythm and emphasis as the story progresses.
This experiment requires full-page rendering.
Open live demoHTML
<div class="narrative-scroll-controller is-arrival" data-narrative-controller>
<aside class="narrative-scroll-controller__stage">
<p class="narrative-scroll-controller__eyebrow">Narrative control</p>
<p class="narrative-scroll-controller__index" data-scene-index>01</p>
<h2 data-scene-title>Arrival</h2>
<p class="narrative-scroll-controller__summary" data-scene-copy>
Establish the world with clarity before rhythm and contrast begin to accelerate.
</p>
<div class="narrative-scroll-controller__progress" aria-hidden="true">
<span data-scene-progress></span>
</div>
</aside>
<div class="narrative-scroll-controller__story">
<section
class="narrative-scene is-active"
data-scene
data-scene-id="arrival"
data-scene-index="01"
data-scene-title="Arrival"
data-scene-copy="Establish the world with clarity before rhythm and contrast begin to accelerate.">
<p class="narrative-scene__eyebrow">Scene 01</p>
<h3>Arrival</h3>
<p>
The narrative opens with generous spacing, low pressure and a broad sense of orientation. Nothing rushes the
reader. The page simply makes the first frame easy to enter.
</p>
</section>
<section
class="narrative-scene"
data-scene
data-scene-id="tension"
data-scene-index="02"
data-scene-title="Tension"
data-scene-copy="Contrast increases, spacing tightens and the page starts to pull the reader forward.">
<p class="narrative-scene__eyebrow">Scene 02</p>
<h3>Tension</h3>
<p>
As the story sharpens, the visual rhythm becomes denser. Color contrast rises and the stage shifts from ambient
to active, making the narrative feel more directional.
</p>
</section>
<section
class="narrative-scene"
data-scene
data-scene-id="pivot"
data-scene-index="03"
data-scene-title="Pivot"
data-scene-copy="A deliberate visual pause gives the reader a moment to reset before the final turn.">
<p class="narrative-scene__eyebrow">Scene 03</p>
<h3>Pivot</h3>
<p>
Midway through the story, the interface creates a short plateau. Rhythm slows, copy gains breathing room and
emphasis shifts from momentum to reflection.
</p>
</section>
<section
class="narrative-scene"
data-scene
data-scene-id="resolve"
data-scene-index="04"
data-scene-title="Resolve"
data-scene-copy="The final state restores composure and makes the structure of the journey feel complete.">
<p class="narrative-scene__eyebrow">Scene 04</p>
<h3>Resolve</h3>
<p>
The closing state does not simply fade out. It gathers the preceding movement into a calmer frame so the ending
feels intentional rather than abrupt.
</p>
</section>
</div>
</div>
CSS
.narrative-scroll-controller {
display: grid;
gap: 22px;
max-width: 1160px;
margin: 0 auto;
padding: 18px;
border-radius: 34px;
transition: background 260ms ease, box-shadow 260ms ease;
}
.narrative-scroll-controller.is-arrival {
background: radial-gradient(circle at top left, rgba(19, 190, 156, 0.2), transparent 42%), #0b1014;
}
.narrative-scroll-controller.is-tension {
background: radial-gradient(circle at top right, rgba(250, 175, 59, 0.22), transparent 40%), #120d0a;
}
.narrative-scroll-controller.is-pivot {
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.08), transparent 42%), #101017;
}
.narrative-scroll-controller.is-resolve {
background: radial-gradient(circle at bottom right, rgba(19, 190, 156, 0.18), transparent 45%), #071112;
}
.narrative-scroll-controller__stage {
align-self: start;
padding: 28px;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(12px);
}
.narrative-scroll-controller__eyebrow,
.narrative-scene__eyebrow {
margin: 0 0 10px;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: rgba(255, 255, 255, 0.58);
}
.narrative-scroll-controller__index {
margin: 0 0 10px;
font-size: clamp(3.2rem, 7vw, 5.6rem);
line-height: 0.9;
color: rgba(255, 255, 255, 0.32);
}
.narrative-scroll-controller__stage h2 {
margin: 0 0 12px;
font-size: clamp(2rem, 4vw, 3.3rem);
line-height: 0.94;
}
.narrative-scroll-controller__summary {
margin: 0;
max-width: 34ch;
color: rgba(255, 255, 255, 0.8);
line-height: 1.72;
}
.narrative-scroll-controller__progress {
height: 4px;
margin-top: 22px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.narrative-scroll-controller__progress span {
display: block;
width: 25%;
height: 100%;
background: linear-gradient(90deg, #faaf3b, #13be9c);
transition: width 220ms ease;
}
.narrative-scroll-controller__story {
display: grid;
gap: 18px;
}
.narrative-scene {
min-height: 70vh;
padding: 30px;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
opacity: 0.5;
transform: scale(0.99);
transition: opacity 220ms ease, transform 220ms ease, border-color 220ms ease;
}
.narrative-scene.is-active {
opacity: 1;
transform: scale(1);
border-color: rgba(255, 255, 255, 0.2);
}
.narrative-scene h3 {
margin: 0 0 14px;
max-width: 12ch;
font-size: clamp(2.2rem, 4vw, 3.8rem);
line-height: 0.94;
}
.narrative-scene p:last-child {
max-width: 56ch;
margin: 0;
color: rgba(255, 255, 255, 0.84);
line-height: 1.8;
font-size: 1.05rem;
}
@media only screen and (min-width: 1040px) {
.narrative-scroll-controller {
grid-template-columns: 360px minmax(0, 1fr);
align-items: start;
}
.narrative-scroll-controller__stage {
position: sticky;
top: 18px;
min-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}
JavaScript
(function () {
const root = document.querySelector('[data-narrative-controller]');
if (!root) return;
const scenes = Array.from(root.querySelectorAll('[data-scene]'));
const stageIndex = root.querySelector('[data-scene-index]');
const stageTitle = root.querySelector('[data-scene-title]');
const stageCopy = root.querySelector('[data-scene-copy]');
const progress = root.querySelector('[data-scene-progress]');
if (!scenes.length || !stageIndex || !stageTitle || !stageCopy || !progress) return;
function activateScene(scene) {
scenes.forEach(item => {
item.classList.toggle('is-active', item === scene);
});
root.classList.remove('is-arrival', 'is-tension', 'is-pivot', 'is-resolve');
root.classList.add(`is-${scene.dataset.sceneId}`);
stageIndex.textContent = scene.dataset.sceneIndex;
stageTitle.textContent = scene.dataset.sceneTitle;
stageCopy.textContent = scene.dataset.sceneCopy;
const index = scenes.indexOf(scene);
progress.style.width = `${((index + 1) / scenes.length) * 100}%`;
}
const observer = new IntersectionObserver(
entries => {
let nextScene = null;
let highestRatio = 0;
entries.forEach(entry => {
if (entry.intersectionRatio > highestRatio) {
highestRatio = entry.intersectionRatio;
nextScene = entry.target;
}
});
if (nextScene) {
activateScene(nextScene);
}
},
{
threshold: [0.2, 0.4, 0.6]
}
);
scenes.forEach(scene => observer.observe(scene));
activateScene(scenes[0]);
})();
What it solves
Helps long-form storytelling maintain momentum and orientation without relying on abrupt transitions or heavy-handed effects.
Narrative Scroll Controller is a showcase-level storytelling pattern where the page shifts tone, pacing and emphasis as the story advances. A sticky stage keeps the reader oriented while each narrative block updates the visual state in a measured, editorial way.
The effect is immersive without becoming theatrical. It guides attention, but still lets the content remain legible and deliberate across the whole arc.