당겨서 새로고침

Pull to Refresh

리스트 맨 위에서 아래로 당기면 스피너가 나타나고, 놓으면 새로고침이 실행되는 모바일 앱 표준 제스처.

다른 이름: Swipe to refresh
···
html
<div class="ptr" id="ptr">
  <div class="spinner" id="spin">&#8595;</div>
  <div class="list" id="plist">
    <div class="item">Item A</div><div class="item">Item B</div><div class="item">Item C</div>
  </div>
</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}
.ptr{position:absolute;inset:0;overflow:hidden;background:var(--bg)}
.spinner{position:absolute;left:50%;top:0;transform:translate(-50%,-40px);width:32px;height:32px;border-radius:50%;
  background:var(--surface);border:1px solid var(--line);display:grid;place-items:center;color:var(--accent);
  font-weight:700;font-size:14px}
.spinner.spin{animation:spin 0.6s linear infinite}
@keyframes spin{to{transform:translate(-50%,10px) rotate(360deg)}}
.list{position:relative;padding:16px 14px;display:grid;gap:8px;transition:transform .2s ease}
.item{padding:11px 14px;border-radius:10px;background:var(--surface);border:1px solid var(--line);
  font-size:clamp(11px,3.2vmin,13px);color:var(--fg)}
.item.new{border-color:var(--accent);color:var(--accent)}
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 list=document.getElementById('plist');
const spin=document.getElementById('spin');
let pulling=false, startY=0, pull=0, refreshing=false;
function applyPull(v){
  pull=Math.max(0,v);
  const c=Math.min(pull,90);
  list.style.transform='translateY('+c+'px)';
  spin.style.transform='translate(-50%,'+(c-40)+'px) rotate('+(c*3)+'deg)';
}
addEventListener('pointerdown',e=>{
  if(refreshing) return;
  fxAuto=false; fxHide();
  if(e.clientY < innerHeight*0.35){ pulling=true; startY=e.clientY; }
});
addEventListener('pointermove',e=>{ if(!pulling) return; applyPull((e.clientY-startY)*0.6); });
addEventListener('pointerup',()=>{
  if(!pulling) return; pulling=false;
  if(pull>60) doRefresh(); else applyPull(0);
});
function doRefresh(){
  refreshing=true; spin.classList.add('spin');
  list.style.transform='translateY(70px)';
  setTimeout(()=>{
    spin.classList.remove('spin');
    const el=document.createElement('div'); el.className='item new'; el.textContent='New item';
    list.insertBefore(el, list.firstChild);
    list.style.transform='translateY(0)';
    setTimeout(()=>{ el.classList.remove('new'); refreshing=false; pull=0; },900);
  },900);
}
async function autoSeq(){
  while(fxAuto){
    if(!refreshing){
      const topX=innerWidth/2;
      fxX=topX; fxY=innerHeight*0.12; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await fxWait(500); if(!fxAuto) break;
      const steps=16;
      for(let i=1;i<=steps;i++){
        if(!fxAuto) break;
        const v=i*6;
        applyPull(v);
        fxY=innerHeight*0.12+v; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
        await new Promise(res=>requestAnimationFrame(res));
      }
      if(!fxAuto) break;
      doRefresh();
      await fxWait(2200);
    } else { await fxWait(200); }
  }
}
autoSeq();

스크롤이 맨 위(`scrollTop === 0`)일 때만 아래 방향 드래그를 당김으로 인식한다. 당긴 거리만큼 리스트 전체를 `translateY`로 밀어내고, 그 위에 있던 스피너/화살표도 함께 드러나며 회전한다 — 당긴 거리가 늘수록 저항이 커지도록 실제 이동량에 0.4~0.6 정도를 곱해 "고무줄" 느낌을 준다.

임계값(보통 60~80px)을 넘기고 놓으면 리스트를 그 위치에 고정한 채 스피너를 계속 돌리는 "로딩 중" 상태로 바꾸고, 실제 데이터를 가져온 뒤 리스트를 원위치로 되돌리며 새 항목을 맨 위에 끼워 넣는다.

네이티브 앱과 달리 웹에서는 브라우저 자체의 pull-to-refresh(새로고침)와 충돌할 수 있어, 컨테이너에 `overscroll-behavior: contain`을 함께 걸어주는 것이 안전하다.

언제 쓰나

피드, 받은메일함처럼 새 데이터가 자주 추가되는 리스트 최상단에 씁니다. 무한스크롤과 함께 쓰면 위아래 모두 자동으로 채워져 편합니다.