Infinite Feed

인피니트 피드

Reach the end of a list and the next page loads and appends itself automatically — no pagination button, just scrolling that never runs dry.

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

Request the next page once scroll position comes within a fixed distance of the bottom — usually one or two viewport heights — rather than waiting to hit the floor exactly. Wait that long and someone runs into empty space before loading finishes, which reads as stutter. While the request is in flight, append a few skeleton rows instead of nothing, signaling "still filling in" rather than a dead end.

Never running out is both the point and the cost. It builds momentum — there's always more — but it also removes any landmark for "how far I've gotten" or "how many there are total," which can be the wrong fit for a list where the count actually matters, like order history or a bounded inbox.

Once too many items pile up, memory and scroll performance suffer. Long-scrolled lists commonly pair this with virtualization, tearing down the DOM for rows that have scrolled out of view.

When to use

Use it for social feeds and discovery streams built around momentum, never running dry. For finite lists where the total count matters — order history, a bounded inbox — plain pagination or a "Load more" button can be more honest.