탭 바

Tab Bar

화면 맨 아래 고정된 3~5개의 아이콘·레이블 버튼으로, 눌린 항목이 곧 지금 보고 있는 최상위 화면을 나타냅니다.

다른 이름: Bottom navigation하단 내비게이션
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="content">
  <div class="tabpanel active">Home feed</div>
  <div class="tabpanel">Search</div>
  <div class="tabpanel">Likes</div>
  <div class="tabpanel">Profile</div>
</div>
<div class="tabbar">
  <button class="tab active"><span class="ic ic0"></span>Home</button>
  <button class="tab"><span class="ic ic1"></span>Search</button>
  <button class="tab"><span class="ic ic2"></span>Likes</button>
  <button class="tab"><span class="ic ic3"></span>Profile</button>
</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}

.content{position:absolute;inset:0 0 15%;display:grid}
.tabpanel{grid-area:1/1;display:none;place-items:center;font-weight:700;font-size:clamp(9px,3.4vmin,13px);color:var(--muted)}
.tabpanel.active{display:grid}
.tabbar{position:absolute;left:0;right:0;bottom:0;height:15%;display:grid;grid-auto-flow:column;
  border-top:1px solid var(--line);background:var(--surface)}
.tab{border:none;background:none;display:grid;justify-items:center;align-content:center;gap:3px;
  font-size:clamp(4.5px,1.7vmin,6.5px);color:var(--muted);font-weight:600;padding:0;transition:transform .12s}
.tab.active{color:var(--accent)}
.ic{width:clamp(6px,3.6vmin,11px);height:clamp(6px,3.6vmin,11px);border-radius:30%;background:currentColor;opacity:.85}
.ic1{border-radius:50%}
.ic2{border-radius:50% 50% 50% 4%;transform:rotate(-45deg)}
.ic3{border-radius:50%;box-shadow:inset 0 0 0 1.6px var(--bg)}
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 tabs=[...document.querySelectorAll('.tab')];
const panels=[...document.querySelectorAll('.tabpanel')];
function setTab(i){
  tabs.forEach((t,j)=>t.classList.toggle('active',j===i));
  panels.forEach((p,j)=>p.classList.toggle('active',j===i));
}
tabs.forEach((t,i)=>t.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); setTab(i); }));
async function autoSeq(){
  let i=0;
  while(pfxAuto){
    const t=tabs[i], r=t.getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
    const fx=(r.left+r.width/2-sr.left)/sr.width, fy=(r.top+r.height/2-sr.top)/sr.height;
    await pfxWalk(fx,fy,fx,fy,350); if(!pfxAuto)break;
    setTab(i);
    t.style.transform='scale(.82)'; await pfxWait(110); t.style.transform='';
    await pfxWait(600);
    i=(i+1)%tabs.length;
  }
}
autoSeq();

탭 바는 "화면 전환"이 아니라 "지금 어느 최상위 섹션에 있는가"를 나타내는 상태다. 그래서 탭을 눌렀을 때 push되는 새 화면처럼 다루지 않고, 각 탭마다 자기만의 콘텐츠와 스크롤 위치·내비게이션 스택을 독립적으로 유지한 채 보이기만 바꾼다(보통 `display`를 토글하거나 뷰를 겹쳐 쌓아둔다). 그래야 홈 탭을 스크롤해두고 검색 탭에 갔다가 돌아와도 스크롤 위치가 그대로다.

iOS는 이 요소를 탭 바(Tab Bar)라 부르고 `UITabBarController`가, Android는 바텀 내비게이션(Bottom Navigation)이라 부르고 Material의 `BottomNavigationView`가 관리한다. 이름과 세부 규칙(아이콘 크기, 배지 위치, 최대 개수)은 플랫폼마다 다르지만 "항상 보이는 최상위 목차"라는 역할은 같다.

항목이 5개를 넘으면 아이콘이 비좁아지고 탭 하나의 의미를 즉시 읽기 어려워진다. 그 이상이 필요하면 마지막 탭을 "더보기"로 묶는 대신, 정말 자주 쓰는 것만 탭 바에 남기고 나머지는 다른 진입 경로(설정, 프로필 메뉴)로 옮기는 편이 낫다.

언제 쓰나

앱 전체를 아우르는 3~5개의 최상위 섹션(홈, 검색, 알림, 프로필 등)이 있을 때 씁니다. 한 화면 안의 하위 카테고리 전환에는 세그먼트 컨트롤이 맞습니다.