세그먼티드 탭 스와이프

Segmented Tabs + Swipe

상단 세그먼티드 컨트롤과 그 아래 콘텐츠 패널이 서로 맞물려 움직이는 패턴. 세그먼트를 탭해도, 콘텐츠를 옆으로 스와이프해도 같은 곳에 닿습니다.

다른 이름: Swipeable segmented control탭+스와이프 동기화
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="segwrap">
  <div class="segbar">
    <span class="segpill" id="segpill"></span>
    <button class="segbtn active">Posts</button><button class="segbtn">Replies</button><button class="segbtn">Likes</button>
  </div>
  <div class="segtrack" id="segtrack">
    <div class="segpage"><div class="card3"></div><div class="card3"></div><div class="card3"></div><div class="card3"></div></div>
    <div class="segpage"><div class="bar w70"></div><div class="bar w50"></div><div class="bar w60"></div></div>
    <div class="segpage"><div class="bar w40"></div><div class="bar w65"></div></div>
  </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}

.segwrap{position:absolute;inset:0}
.segbar{position:relative;display:grid;grid-template-columns:repeat(3,1fr);margin:8% 6% 4%;border-bottom:1px solid var(--line)}
.segpill{position:absolute;left:0;bottom:-1px;width:33.333%;height:2px;background:var(--accent);transform:translateX(0)}
.segbtn{border:none;background:none;padding:0 0 6%;font-size:clamp(6px,2.3vmin,9px);font-weight:700;color:var(--muted)}
.segbtn.active{color:var(--fg)}
.segtrack{display:flex;width:300%;height:70%;touch-action:none}
.segpage{width:33.333%;padding:4% 6%;display:grid;gap:8%;align-content:start}
.card3{aspect-ratio:1.6;border-radius:10%;background:var(--line)}
.bar{height:clamp(3px,2.2vmin,6px);border-radius:3px;background:var(--line)}
.w70{width:70%}.w50{width:50%}.w60{width:60%}.w40{width:40%}.w65{width:65%}
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('segtrack'), pill=document.getElementById('segpill'), btns=[...document.querySelectorAll('.segbtn')];
let idx=0;
function render(p){
  track.style.transform='translateX('+(-p*33.333)+'%)';
  pill.style.transform='translateX('+(p*100)+'%)';
  const nearest=Math.round(p);
  btns.forEach((b,i)=>b.classList.toggle('active',i===nearest));
}
function go(i){
  idx=Math.max(0,Math.min(2,i));
  track.style.transition='transform .3s ease'; pill.style.transition='transform .3s ease';
  render(idx);
}
btns.forEach((b,i)=>b.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); go(i); }));
let dragging=false,startX=0;
track.addEventListener('pointerdown',e=>{ pfxAuto=false; pfxHide(); dragging=true; startX=e.clientX; track.style.transition='none'; pill.style.transition='none'; });
addEventListener('pointermove',e=>{
  if(!dragging)return;
  const w=screenEl.clientWidth||100, delta=(e.clientX-startX)/w;
  render(Math.max(0,Math.min(2,idx-delta)));
});
addEventListener('pointerup',e=>{
  if(!dragging)return; dragging=false;
  const w=screenEl.clientWidth||100, delta=(e.clientX-startX)/w;
  go(Math.round(idx-delta));
});
go(0);
async function autoSeq(){
  while(pfxAuto){
    for(let i=0;i<3;i++){
      if(!pfxAuto)break;
      go(i);
      await pfxWalk(0.5,0.5,0.5,0.5,300); await pfxWait(700);
    }
  }
}
autoSeq();

양방향으로 묶는다. 세그먼트를 탭하면 아래 콘텐츠 패널이 해당 인덱스로 애니메이션하고, 반대로 콘텐츠 패널을 드래그하면 그 진행률(정수 인덱스 + 소수 부분)을 그대로 읽어 위쪽 인디케이터(알약 모양 밑줄)의 위치를 계산한다. 손을 떼기 전까지도 인디케이터가 손가락을 따라 실시간으로 움직여야, 컨트롤과 콘텐츠가 "같은 것"으로 느껴진다.

탭 바와 헷갈리기 쉽지만 역할이 다르다. 탭 바는 서로 다른 최상위 섹션 사이를 오가며 대개 스와이프를 지원하지 않는다. 세그먼티드 탭은 한 화면 안에서 성격이 다른 하위 콘텐츠(게시물/댓글/좋아요처럼)를 오가는 것이라, 스와이프로 전환하는 것이 자연스러운 기본값이다.

인디케이터 위치는 `segmentWidth * (index + dragFraction)`처럼 연속값으로 계산해야 한다. 정수 인덱스에서만 스냅하고 중간 과정을 생략하면, 드래그 중에는 인디케이터가 안 움직이다가 손을 뗀 순간 갑자기 튀는 것처럼 보인다.

언제 쓰나

한 화면 안에서 성격이 다른 하위 콘텐츠(게시물/댓글/좋아요 등)를 전환할 때 씁니다. 앱 전체를 아우르는 최상위 내비게이션에는 탭 바를 씁니다.