스크롤 스냅

Scroll Snap

스크롤을 놓으면 가장 가까운 항목에 자동으로 딱 맞춰 멈추는 CSS 기능. JS 없이 갤러리·캐러셀을 만들 수 있습니다.

다른 이름: Carousel snap
···
html
<div class="snap" id="snap">
  <div class="scard">1</div><div class="scard">2</div><div class="scard">3</div><div class="scard">4</div><div class="scard">5</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}
.snap{position:absolute;inset:0;display:flex;gap:12px;overflow-x:auto;overflow-y:hidden;
  scroll-snap-type:x mandatory;padding:0 40%;align-items:center;cursor:grab;scrollbar-width:none}
.snap::-webkit-scrollbar{display:none}
.scard{flex:0 0 auto;width:min(46%,120px);aspect-ratio:1/1;border-radius:16px;scroll-snap-align:center;
  display:grid;place-items:center;font-size:clamp(16px,5vmin,22px);font-weight:800;color:#fff;
  background:linear-gradient(135deg,var(--accent),var(--accent-2))}
.scard:nth-child(even){background:linear-gradient(135deg,var(--accent-3),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 snap=document.getElementById('snap');
addEventListener('wheel',()=>{fxAuto=false;fxHide();},{capture:true,passive:true});
addEventListener('touchstart',()=>{fxAuto=false;fxHide();},{capture:true,passive:true});
let dragging=false, dragStartX=0, dragStartScroll=0;
snap.addEventListener('pointerdown',e=>{
  fxAuto=false; fxHide();
  dragging=true; dragStartX=e.clientX; dragStartScroll=snap.scrollLeft;
  snap.setPointerCapture(e.pointerId); snap.style.cursor='grabbing';
});
addEventListener('pointermove',e=>{ if(!dragging) return; snap.scrollLeft=dragStartScroll-(e.clientX-dragStartX); });
addEventListener('pointerup',()=>{ if(!dragging) return; dragging=false; snap.style.cursor='grab'; });
const cards=[...snap.querySelectorAll('.scard')];
async function autoSeq(){
  let i=0;
  while(fxAuto){
    await fxWait(1500); if(!fxAuto) break;
    i=(i+1)%cards.length;
    cards[i].scrollIntoView({behavior:'smooth', inline:'center', block:'nearest'});
    const r=snap.getBoundingClientRect();
    fxY=r.top+r.height/2;
    const steps=16;
    for(let s=0;s<=steps;s++){
      if(!fxAuto) break;
      fxX=r.left+r.width*(0.7-0.4*(s/steps));
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await new Promise(res=>requestAnimationFrame(res));
    }
  }
}
autoSeq();

스크롤 컨테이너에 `scroll-snap-type: x mandatory`(가로) 또는 `y mandatory`(세로)를 주고, 각 항목에는 `scroll-snap-align: center`(또는 `start`)를 준다. 이게 전부다 — 브라우저가 관성 스크롤이 끝나는 지점을 항목 경계에 맞춰준다.

좌우 끝 항목도 화면 중앙까지 스크롤될 수 있도록, 컨테이너에 `padding`(항목 폭의 절반 정도)을 좌우로 줘야 한다. 안 그러면 첫/마지막 항목이 가장자리에 붙어버려 다른 항목들과 정렬이 어긋나 보인다.

`mandatory`는 반드시 스냅 지점에 멈추고, `proximity`는 스냅 지점 근처에서만 스냅한다 — 빠르게 휙 넘기는 스크롤이 잦으면 `proximity`가 덜 뻑뻑하게 느껴진다.

언제 쓰나

이미지 갤러리, 온보딩 슬라이드, 상품 캐러셀처럼 "한 번에 하나씩 보여주는" 가로/세로 목록에 씁니다.