스토리 뷰어

Story Viewer

화면 위쪽의 얇은 진행 바들이 순서대로 차오르며 자동으로 다음 스토리로 넘어가는 전체 화면 뷰어. 화면 좌우를 탭하면 수동으로 이전·다음으로 건너뜁니다.

다른 이름: Stories스토리
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="svsegs"><span class="svseg"><i id="f0"></i></span><span class="svseg"><i id="f1"></i></span><span class="svseg"><i id="f2"></i></span></div>
<div class="svbody"></div>
<div class="svzone left" id="zl"></div><div class="svzone right" id="zr"></div></div><div class="home"></div></div></div>
css
.stage{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.phone{position:relative;height:90vmin;max-height:640px;aspect-ratio:9/19.5;background:#08080c;
  border-radius:20%/8.6%;box-shadow:0 0 0 2px rgba(255,255,255,.08) inset,0 12px 30px rgba(0,0,0,.35)}
.phone .notch{position:absolute;left:50%;top:0;transform:translateX(-50%);width:38%;height:5.6%;
  background:#08080c;border-radius:0 0 46% 46%;z-index:80}
.phone .screen{position:absolute;top:7.6%;left:3%;right:3%;bottom:3%;border-radius:11%/4.6%;overflow:hidden;
  background:var(--surface);color:var(--fg)}
.phone .home{position:absolute;left:50%;bottom:1.1%;transform:translateX(-50%);width:32%;height:3px;
  border-radius:2px;background:rgba(255,255,255,.45);z-index:80}
.pfx{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:color-mix(in srgb,var(--accent) 55%,transparent);
  box-shadow:0 0 0 6px color-mix(in srgb,var(--accent) 15%,transparent);pointer-events:none;z-index:999;
  transition:opacity .2s}
.pfx.hold{width:30px;height:30px;margin:-15px 0 0 -15px;
  box-shadow:0 0 0 9px color-mix(in srgb,var(--accent) 20%,transparent);
  transition:width .5s linear,height .5s linear,margin .5s linear,box-shadow .5s linear,opacity .2s}

.svsegs{position:absolute;top:5%;left:4%;right:4%;display:flex;gap:4%;z-index:10}
.svseg{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.35);overflow:hidden}
.svseg i{display:block;height:100%;width:0%;background:#fff;border-radius:2px}
.svbody{position:absolute;inset:0;background:linear-gradient(160deg,var(--accent),var(--accent-3))}
.svzone{position:absolute;top:0;bottom:0;width:34%;z-index:5}
.svzone.left{left:0}.svzone.right{right:0}
js
const pfx=document.createElement('div');pfx.className='pfx';document.body.appendChild(pfx);
const screenEl=document.getElementById('screen');
let pfxAuto=true;
function pfxHide(){pfx.style.opacity='0';}
addEventListener('pointerdown',()=>{pfxAuto=false;pfxHide();},{capture:true});
function pfxAt(fx,fy){
  const r=screenEl.getBoundingClientRect();
  const x=r.left+r.width*fx, y=r.top+r.height*fy;
  pfx.style.transform='translate('+x+'px,'+y+'px)';
  return {x,y};
}
function pfxWalk(x0,y0,x1,y1,ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){
      if(!pfxAuto) return res();
      const p=Math.min(1,(now-start)/ms), e=1-Math.pow(1-p,3);
      pfxAt(x0+(x1-x0)*e, y0+(y1-y0)*e);
      if(p<1) requestAnimationFrame(step); else res();
    }
    requestAnimationFrame(step);
  });
}
function pfxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!pfxAuto) return res(); if(now-start<ms) requestAnimationFrame(step); else res(); }
    requestAnimationFrame(step);
  });
}

const segs=[document.getElementById('f0'),document.getElementById('f1'),document.getElementById('f2')];
const DUR=1900;
let idx=0, elapsed=0;
function reset(i){ segs.forEach((s,j)=>{ s.style.width= j<i?'100%':'0%'; }); }
function go(i){ idx=Math.max(0,Math.min(segs.length-1,i)); elapsed=0; reset(idx); }
document.getElementById('zl').addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); go(idx>0?idx-1:0); });
document.getElementById('zr').addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); go(Math.min(segs.length-1,idx+1)); });
async function autoSeq(){
  go(0);
  let last=performance.now();
  while(pfxAuto){
    const now=performance.now(); elapsed+=now-last; last=now;
    const frac=Math.min(1,elapsed/DUR);
    segs[idx].style.width=(frac*100)+'%';
    if(frac>=1){ if(idx<segs.length-1) go(idx+1); else { await pfxWait(500); go(0); last=performance.now(); } }
    pfxAt(idx%2===0?0.78:0.22, 0.5);
    await new Promise(r=>requestAnimationFrame(r));
  }
}
autoSeq();

상단에 스토리 개수만큼 얇은 바를 나란히 두고, 지금 보는 스토리에 해당하는 바 하나만 너비를 0%에서 100%까지 고정된 시간(보통 5초 안팎) 동안 채운다. 다 채워지면 다음 바로 넘어가 다시 채우기 시작하고, 이미 지나간 바는 100%로 남겨 "여기까지 봤다"는 진행 상황을 알려준다.

화면을 세로로 나눠 왼쪽·오른쪽 탭 존을 따로 둔다 — 오른쪽을 탭하면 채워지던 시간을 기다리지 않고 즉시 다음으로, 왼쪽을 탭하면 이전으로 건너뛴다. 화면을 누르고 있는 동안(롱 프레스)은 채우기를 멈춰 텍스트가 많은 스토리를 편하게 읽을 시간을 준다.

일반 캐러셀과 달리 "저절로 진행된다"는 점이 핵심이라, 자동 재생 타이머와 탭 제스처가 서로 충돌하지 않게 해야 한다 — 탭으로 건너뛴 순간에는 새 스토리의 타이머를 처음부터 다시 시작해야지, 이전 진행률을 이어받으면 안 된다.

언제 쓰나

짧은 세로형 콘텐츠를 순서대로 훑어보되 "몇 번째까지 봤는지"가 한눈에 보여야 하는 곳(스토리, 튜토리얼 슬라이드)에 씁니다. 오래 머물며 읽는 콘텐츠에는 자동 진행이 맞지 않습니다.