온보딩 캐러셀

Onboarding Carousel

앱을 처음 열었을 때 스와이프로 넘겨보는 2~4장의 소개 화면. 마지막 장에서 "시작하기" 버튼으로 이어집니다.

다른 이름: Intro slides앱 소개 슬라이드
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="obwrap">
  <div class="obtrack" id="obtrack">
    <div class="obslide"><div class="obicon i0"></div><p>Track your habits</p></div>
    <div class="obslide"><div class="obicon i1"></div><p>Get gentle reminders</p></div>
    <div class="obslide"><div class="obicon i2"></div><p>See your progress</p></div>
  </div>
</div>
<div class="obdots"><span class="active"></span><span></span><span></span></div>
<button class="obbtn" id="obbtn">Next</button></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}

.obwrap{position:absolute;inset:0 0 20%;overflow:hidden}
.obtrack{display:flex;height:100%;width:300%;touch-action:none}
.obslide{width:33.333%;display:grid;place-items:center;gap:8%;padding:10% 12%;text-align:center}
.obicon{width:clamp(24px,16vmin,46px);height:clamp(24px,16vmin,46px);border-radius:26%;background:var(--accent);opacity:.85}
.i1{border-radius:50%}
.i2{border-radius:50% 50% 50% 6%}
.obslide p{margin:0;font-weight:700;font-size:clamp(7px,2.6vmin,10px);color:var(--fg)}
.obdots{position:absolute;left:0;right:0;bottom:11%;display:flex;justify-content:center;gap:6px}
.obdots span{width:5px;height:5px;border-radius:50%;background:var(--line);transition:width .2s,background .2s}
.obdots span.active{background:var(--accent);width:14px;border-radius:3px}
.obbtn{position:absolute;left:8%;right:8%;bottom:3%;height:9%;border:none;border-radius:999px;background:var(--accent);
  color:#fff;font-weight:700;font-size:clamp(7px,2.6vmin,9.5px)}
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 track=document.getElementById('obtrack'), dots=[...document.querySelectorAll('.obdots span')], btn=document.getElementById('obbtn');
let idx=0;
function go(i){
  idx=Math.max(0,Math.min(2,i));
  track.style.transition='transform .35s ease';
  track.style.transform='translateX('+(-idx*33.333)+'%)';
  dots.forEach((d,j)=>d.classList.toggle('active',j===idx));
  btn.textContent= idx===2?'Get Started':'Next';
}
btn.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); go(idx===2?0:idx+1); });
let dragging=false,startX=0;
track.addEventListener('pointerdown',e=>{ pfxAuto=false; pfxHide(); dragging=true; startX=e.clientX; track.style.transition='none'; });
addEventListener('pointermove',e=>{
  if(!dragging)return;
  track.style.transform='translateX(calc('+(-idx*33.333)+'% + '+(e.clientX-startX)+'px))';
});
addEventListener('pointerup',e=>{
  if(!dragging)return; dragging=false;
  const dx=e.clientX-startX;
  if(dx<-40) go(idx+1); else if(dx>40) go(idx-1); else go(idx);
});
go(0);
async function autoSeq(){
  while(pfxAuto){
    for(let i=0;i<3;i++){
      if(!pfxAuto)break;
      go(i);
      await pfxWalk(0.82,0.7,0.82,0.7,250); if(!pfxAuto)break;
      await pfxWait(650); if(!pfxAuto)break;
      if(i<2) await pfxWalk(0.82,0.7,0.18,0.7,380);
    }
  }
}
autoSeq();

슬라이드를 가로로 나란히 이어붙인 트랙 하나를 `transform: translateX(-index * 100%)`로 옮기는 방식이 기본이다. 드래그 중에는 손가락 이동량을 그대로 더해 즉각 반응하게 하고, 손을 뗄 때 이동 거리·속도로 다음/이전 슬라이드로 넘길지 원래 자리로 되돌릴지를 정한다.

점 인디케이터(dots)는 장식이 아니라 "총 몇 장 중 지금 몇 번째"라는 진행 정보다 — 현재 위치의 점을 넓혀 강조하면 스크린리더 없이도 위치를 짐작할 수 있다. 마지막 장에서만 버튼 레이블을 "다음"에서 "시작하기"로 바꿔, 여기가 끝이라는 신호를 명시적으로 준다.

온보딩은 매번 봐야 하는 필수 관문이 아니다 — 오른쪽 위에 "건너뛰기"를 항상 노출해 언제든 빠져나갈 수 있게 하고, 값을 입력받는 화면(회원가입 등)과 섞지 않는 것이 좋다. 기능 설명은 실제 화면을 처음 마주쳤을 때의 코치마크로 대신하는 것이 더 잘 기억된다는 연구도 있다.

언제 쓰나

앱의 핵심 가치를 3장 이내로 빠르게 전달하고 싶은 첫 실행 시점에 씁니다. 설명할 화면이 많다면 캐러셀을 늘리기보다 앱 안에서 그때그때 코치마크로 나누는 편이 낫습니다.