Pull to Refresh

당겨서 새로고침

The standard mobile pattern: pull down from the top of a list to reveal a spinner, release to trigger a refresh.

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

Only treat a downward drag as a pull when the list is already scrolled to the very top (`scrollTop === 0`). Push the whole list down with `translateY` by the drag distance, revealing a spinner or arrow above it that rotates as you pull — multiply the raw distance by roughly 0.4–0.6 so resistance grows as you pull further, giving it a rubber-band feel.

Release past a threshold (usually 60–80px) and the list locks in place while the spinner keeps turning in a "loading" state; once the real data arrives, animate the list back to rest and insert the new item at the top.

Unlike native apps, on the web this can collide with the browser's own pull-to-refresh, so it's safer to also set `overscroll-behavior: contain` on the container.

When to use

Use it at the top of feeds and inboxes where new data arrives often. Pairs well with infinite scroll for a list that fills from both ends.