Segmented Tabs + Swipe

세그먼티드 탭 스와이프

A segmented control up top stays locked in sync with the content panel below it — tapping a segment and swiping the content both land on the same page.

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

Bind the two directions to each other. Tapping a segment animates the content panel to that index; dragging the content panel, in turn, reads its progress (an integer index plus a fractional part) to compute the position of the indicator above — the pill or underline. The indicator needs to keep tracking the finger in real time, before release, for the control and the content to feel like the same thing.

Easy to confuse with a tab bar, but the roles differ. A tab bar moves between separate top-level sections and usually doesn't support swiping. Segmented tabs live inside one screen, switching between related sub-content — posts, replies, likes — so swiping between them is a natural default.

The indicator's position should be computed as a continuous value, `segmentWidth * (index + dragFraction)`, not just snapped at integer indices — skip the in-between and the indicator sits frozen during the drag, then suddenly jumps the moment you let go.

When to use

Use it to switch between related sub-content within one screen — posts, replies, likes. For top-level navigation spanning the whole app, use a tab bar instead.