Scrollytelling

스크롤리텔링

A story that unfolds step by step as you scroll — scrolling itself becomes the page-turn.

Also known as: Scroll-driven storytelling
···
html
<div class="scr" id="scr">
  <div class="step" data-i="0"><div class="visual"></div><p>Step 1 — scroll begins</p></div>
  <div class="step" data-i="1"><div class="visual"></div><p>Step 2 — visual reacts</p></div>
  <div class="step" data-i="2"><div class="visual"></div><p>Step 3 — the reveal</p></div>
</div>
<div class="dots" id="scrDots"><span></span><span></span><span></span></div>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.scr{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;scrollbar-width:none}
.scr::-webkit-scrollbar{display:none}
.step{scroll-snap-align:start;height:100%;display:grid;place-items:center;gap:10px;justify-items:center;padding:10px}
.step .visual{width:clamp(40px,16vmin,64px);height:clamp(40px,16vmin,64px);border-radius:16px;transition:background .3s,transform .3s,border-radius .3s}
.step[data-i="0"] .visual{background:var(--accent)}
.step[data-i="1"] .visual{background:var(--accent-2);transform:rotate(20deg)}
.step[data-i="2"] .visual{background:var(--accent-3);border-radius:50%}
.step p{margin:0;font-size:clamp(11px,3.2vmin,13px);color:var(--muted);text-align:center}
.dots{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:grid;gap:6px;z-index:5}
.dots span{width:6px;height:6px;border-radius:50%;background:var(--line);transition:background .2s,transform .2s}
.dots span.on{background:var(--accent);transform:scale(1.4)}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const scr=document.getElementById('scr');
const dots=[...document.querySelectorAll('#scrDots span')];
const steps=[...scr.querySelectorAll('.step')];
function setActive(i){ dots.forEach((d,k)=>d.classList.toggle('on',k===i)); }
const io=new IntersectionObserver(list=>{
  list.forEach(en=>{ if(en.isIntersecting) setActive(Number(en.target.dataset.i)); });
},{root:scr, threshold:0.6});
steps.forEach(s=>io.observe(s));
setActive(0);
addEventListener('wheel',()=>{fxAuto=false;fxHide();},{capture:true,passive:true});
addEventListener('touchstart',()=>{fxAuto=false;fxHide();},{capture:true,passive:true});
async function autoSeq(){
  let i=0;
  while(fxAuto){
    await fxWait(1500); if(!fxAuto) break;
    i=(i+1)%steps.length;
    steps[i].scrollIntoView({behavior:'smooth', block:'start'});
    const r=scr.getBoundingClientRect();
    fxX=r.left+r.width*0.85; fxY=r.top+r.height*0.3;
    fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
    const n=14;
    for(let s=0;s<=n;s++){
      if(!fxAuto) break;
      fxY=r.top+r.height*(0.3+0.5*(s/n));
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await new Promise(res=>requestAnimationFrame(res));
    }
  }
}
autoSeq();

Use `IntersectionObserver` to watch how much of each step section has entered the viewport, and once it crosses a threshold, mark that step "active" and update the accompanying visual (color, icon, number). It performs better and reads simpler than reading scroll position on every frame yourself.

Give each step `scroll-snap-align` and scrolling comes to rest at step boundaries — a "turning one page at a time" feel; leave it off for a smoother, continuous feel. Pick based on the story's rhythm.

The classic implementation (e.g. scrollama) uses a "sticky + steps" layout: a pinned visual area with text steps scrolling past beside it. On narrow screens the visual and text can overlap and hurt readability, which is why mobile often gets its own layout.

When to use

Use it for data journalism, product stories, annual reports — content meant to be read in order. Give people who want to skip ahead a table of contents or a skip button.