Story Viewer

스토리 뷰어

A full-screen viewer where thin progress bars fill one at a time across the top, auto-advancing between stories — tapping the left or right side jumps back or ahead manually.

Also known as: 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();

Lay one thin bar per story across the top; only the active story's bar fills from 0% to 100% over a fixed duration, usually around five seconds. Once it fills, move to the next bar and start filling that one — bars already finished stay locked at 100%, showing how far along you are.

Split the screen into left and right tap zones. Tapping right skips ahead immediately instead of waiting for the fill to finish; tapping left jumps back. Holding a finger down pauses the fill, giving more time to read a text-heavy story.

Unlike an ordinary carousel, the defining trait is that it advances on its own — the autoplay timer and the tap gesture need to stay in sync. The moment someone taps past a story, the new one's timer has to restart from zero, never pick up where the previous story's progress left off.

When to use

Use it for short, sequential vertical content where "how far along am I" needs to be visible at a glance — stories, tutorial slides. Autoplay is the wrong fit for content meant to be lingered over.