인피니트 피드

Infinite Feed

리스트 끝에 다다르면 다음 데이터를 자동으로 불러와 이어붙이는 패턴. 페이지네이션 버튼 없이 스크롤만으로 콘텐츠가 계속됩니다.

다른 이름: Infinite scroll무한 스크롤
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen"><div class="feed" id="feed"><div class="fcard"></div><div class="fcard"></div><div class="fcard"></div></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}

.feed{position:absolute;inset:0;overflow-y:auto;padding:6%}
.fcard{height:clamp(26px,17vmin,46px);border-radius:10%;background:var(--line);margin-bottom:6%}
.fcard.sk{position:relative;overflow:hidden;opacity:.6}
.fcard.sk::after{content:'';position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--fg) 8%,transparent),transparent);
  animation:sk 1.1s infinite}
@keyframes sk{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
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 feed=document.getElementById('feed');
let count=3, loading=false;
function addSkeletons(n){ for(let i=0;i<n;i++){ const d=document.createElement('div'); d.className='fcard sk'; feed.appendChild(d); } }
function resolveSkeletons(){ [...feed.querySelectorAll('.fcard.sk')].forEach(d=>d.classList.remove('sk')); }
async function loadMore(){
  if(loading||count>=12) return; loading=true;
  addSkeletons(2);
  await pfxWait(650);
  resolveSkeletons(); count+=2; loading=false;
}
feed.addEventListener('scroll',()=>{ if(feed.scrollTop+feed.clientHeight>feed.scrollHeight-60) loadMore(); });
async function autoSeq(){
  while(pfxAuto){
    while(pfxAuto && count<12){
      const start=performance.now();
      while(pfxAuto){
        const t=(performance.now()-start)/650; if(t>=1)break;
        feed.scrollTop=feed.scrollTop+2; pfxAt(0.5,0.55);
        await new Promise(r=>requestAnimationFrame(r));
      }
      if(!pfxAuto)break;
      if(feed.scrollTop+feed.clientHeight>feed.scrollHeight-60) await loadMore();
      await pfxWait(150);
    }
    if(!pfxAuto)break;
    await pfxWait(900);
    feed.scrollTop=0;
    while(feed.children.length>3) feed.removeChild(feed.lastChild);
    count=3;
    await pfxWait(500);
  }
}
autoSeq();

화면이 바닥에서 일정 거리(보통 뷰포트 한두 개 높이) 안으로 들어오면 다음 페이지를 미리 요청한다. 스크롤이 완전히 바닥에 닿을 때까지 기다리면 로딩이 끝나기 전에 빈 공간을 만나 버벅임으로 느껴진다. 요청이 오가는 동안에는 빈 화면 대신 스켈레톤 몇 줄을 먼저 붙여 "계속 채워지고 있다"는 신호를 준다.

끝이 없다는 것 자체가 장단점이다. 다음 콘텐츠로 이어지는 흐름(momentum)을 만들지만, 반대로 "지금까지 본 것의 끝"이나 "전체 개수"를 가늠할 기준점이 사라진다 — 주문 내역이나 받은메일함처럼 총 개수가 의미 있는 목록에는 안 맞을 수 있다.

로드한 항목이 지나치게 쌓이면 메모리와 스크롤 성능이 나빠진다. 오래 스크롤한 리스트는 화면 밖으로 나간 항목의 DOM을 걷어내는 가상화(virtualization)를 함께 쓰는 것이 일반적이다.

언제 쓰나

소셜 피드, "당신을 위한" 발견형 스트림처럼 끊김 없이 계속 보게 만드는 게 목표인 곳에 씁니다. 총 개수가 의미 있는 목록에는 일반 페이지네이션이나 "더 보기" 버튼이 더 정직할 수 있습니다.