스크롤리텔링

Scrollytelling

스크롤 위치에 따라 이야기가 단계별로 전개되는 기법. 스크롤이 곧 "페이지 넘기기"가 됩니다.

다른 이름: 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();

`IntersectionObserver`로 각 스텝 섹션이 뷰포트에 얼마나 들어왔는지 관찰하다가, 특정 임계값(threshold)을 넘으면 그 스텝을 "활성"으로 표시하고 함께 있는 시각 요소(색상·아이콘·수치)를 바꾼다. 스크롤 이벤트를 직접 매 프레임 읽는 것보다 성능이 좋고 코드도 단순하다.

각 스텝에 `scroll-snap-align`을 주면 스크롤이 스텝 경계에서 멈춰 "한 장씩 넘기는" 느낌이 나고, 주지 않으면 부드럽게 이어지는 느낌이 난다 — 이야기의 리듬에 맞게 고른다.

전통적인 구현(예: scrollama)은 고정된(sticky) 시각 영역 옆으로 텍스트 스텝들이 지나가는 "sticky + step" 구조를 쓴다. 좁은 화면에서는 시각 영역과 텍스트가 겹쳐 가독성이 떨어질 수 있어 모바일용 레이아웃을 별도로 설계하는 경우가 많다.

언제 쓰나

데이터 저널리즘, 제품 스토리, 연차보고서처럼 "순서대로 읽혀야 하는" 콘텐츠에 씁니다. 건너뛰고 싶은 사용자를 위해 목차/스킵 버튼도 함께 두세요.