Skip to content

탭 바 (TabNav) ​

탭 바의 축(size · divider · fill)과 탭 단위 슬롯을 확인하고, 사용 기준과 호출 방법을 찾아보세요.

Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마
실제 .ds-tab-* 클래스로 렌더해요 — 탭을 눌러 활성 전환도 확인할 수 있습니다. 탭은 의도적으로 각진 모양이라 rounded-* 유틸은 먹지 않습니다. 활성 2px 검은 바는 하단 회색 구분선과 별개라, divider 를 꺼도 남습니다.
// 호스트 상태
const myTabs = [
  { id: 'a', label: '연차 현황' },
  { id: 'b', label: '연차/휴가 결재' },
];

<div x-data="tabNav({
  getTabs: () => myTabs,            // () => TabNavItem[]
  getActive: () => activeId,        // () => string
  onSelect: (id) => switchTo(id),   // (id) => void
})" class="shrink-0">
  <!-- include "../../packages/components/html/tab-nav.html" -->
</div>

size · divider · fill 과 탭 단위 슬롯(아이콘 · 보조 텍스트 · dot · badge · disabled)을 켜고 끄면, 실제 .ds-tab-* 클래스로 렌더된 탭 바와 호출 코드가 함께 바뀝니다. 축을 하나씩 따로 펼친 것은 「모아보기」 탭에 있습니다.

규칙 (강제) · 코딩 에이전트용

TabNav 강제 규칙. 상세는 아래 본문.

마크업

  • 호스트가 x-data="tabNav({...})"로 감싸고 packages/components/html/tab-nav.html을 include 합니다. .ds-tab-nav 마크업을 손으로 짓지 마세요.
  • 활성 클래스는 문자열 tab-active 입니다(tab-nav.html의 :class 바인딩). ⚠️ SubTab은 is-active 로 다릅니다 — 헷갈리지 마세요.
  • testid는 자동 생성(tabnav-<id>)이에요 — 직접 달지 마세요.
  • 옵션: size(기본 md) · divider(기본 true) · fill(기본 false).

⚠️ 트랩 ① 탭 id는 4곳에서 완전히 같은 문자열이어야 합니다 (실제로 터진 버그) 탭 id는 동시에 ① TabNavItem.id ② TAB_IDS의 URL 세그먼트 ③ x-show="activeTab === '...'" 비교 문자열 ④ loadTabData() 분기 — 네 군데의 리터럴이 전부 일치해야 합니다. 하나라도 어긋나면 탭이 조용히 백지로 렌더됩니다(에러 없음).

  • 실제 사고: 스케줄러 탭 id를 schedule → scheduler로 바꿨는데 x-show 비교 7개가 남아서, 커밋 두 개(48b1c507, 8cd003f2)로 겨우 고쳤어요.
  • id 변경은 원자적으로 — 레포 전체(.ts·.html·라우터)에서 옛 id가 0건이 될 때까지 한 번에 바꾸세요.

⚠️ 트랩 ② popstate는 activeTab만 세팅하면 안 됩니다 뒤로/앞으로 가기 핸들러는 activeTab 세팅 + 그 탭의 데이터 로더 호출을 같이 해야 해요. 라우터 스토어는 path만 동기화하지 데이터를 안 불러옵니다 — activeTab만 바꾸면 패널이 빈 채로 남아요. 올바른 예: team-manager · hr-system app.ts의 window.addEventListener("popstate", …) 핸들러 (둘 다 탭 세팅 뒤 데이터 로더를 호출합니다).

  • URL 별칭(/overtime·/offsite → work)을 쓴다면 라우터와 initialTabFromUrl 양쪽에 같은 매핑을 넣어야 합니다. 한쪽만 넣으면 popstate가 빈 화면을 만들어요.

⚠️ 트랩 ③ 탭은 의도적으로 각진 모양입니다.ds-tab-item은 전역 <button> 4px radius 강제(design-system.css의 button:not([class*="bg-"]):not(.ds-tab-item)… 규칙)에서 제외돼 있고, .ds-tab-item 블록 자체가 border-radius: 0 !important를 씁니다. rounded-* 유틸을 붙이지 마세요(안 먹습니다). 그 셀렉터의 :not(.ds-tab-item) 제외를 지우지도 마세요.

⚠️ 트랩 ④ 죽은 CSS 셀렉터 — 여기 고쳐도 아무 일도 안 일어납니다 admin · team-manager main.css의 nav.ds-tab-nav > button.ds-tab-item 셀렉터(자식 결합자 > 주의)는 아무것도 매치하지 않습니다. 공유 파셜은 버튼을 한 단계 더 깊이(nav > div > template > button) 넣거든요. 탭 색을 여기서 고치려 하지 마세요. (자손 결합자를 쓰는 nav.ds-tab-nav .ds-tab-item::after는 살아 있습니다 — 헷갈리지 마세요.)

⚠️ 트랩 ⑤ admin은 탭 크기를 덮어씁니다 문서의 md=40px는 정본 기준이에요. admin main.css의 최상위 .ds-tab-item 블록(min-height: 34px · font-size: 13px)이 이를 덮어씁니다. ⚠️ 같은 파일에 nav.ds-tab-nav > button.ds-tab-item으로 시작하는 블록도 있는데 그건 트랩 ④의 죽은 셀렉터예요 — 자식 결합자가 없는 쪽이 살아있는 오버라이드입니다. 크기 얘기를 할 땐 앱을 확인하세요.

가시성은 allowlist로x-show는 activeTab === 'employees'처럼 켤 탭을 지목하세요. activeTab !== '...' 방식(blacklist)은 나중에 추가되는 탭에 자동으로 열려버려요(fail-open). 실제로 admin dashboard.html의 x-show="activeTab !== 'embed'"가 그런 형태예요.

좁아질 때 — 버튼은 .ds-tab-nav-row, 넘침은 셰브론 → 좁아질 때

  • 탭 바 오른쪽에 기능 버튼을 둘 때 absolute로 겹쳐 올리지 마세요. 좁은 화면에서 버튼이 탭 글자를 덮습니다(근태 현황·스케줄러 400px, 2026-09-17). .ds-tab-nav-row > .ds-tab-nav-row__tabs(tabNav 호스트) + .ds-tab-nav-row__actions(버튼)로 나란히 둡니다. 하단 구분선은 두 칸이 같은 선을 이어 그립니다.
  • 좁아지면 순서는 ① 버튼 아이콘만 → ② 탭 가로 스크롤 + < >. 판정은 TabNav.ts의 tabNavSync()가 폭을 재서 합니다(.is-compact를 손으로 붙이지 마세요). 아이콘만 남는 버튼에는 .ds-ico가 필수이고, 라벨은 툴팁으로 자동 이관됩니다.
  • 셰브론은 넘칠 때 자동입니다(넘치는 방향에만). 탭 스크롤 박스는 아래로 4px 여유를 둬 활성 밑줄이 잘리지 않으므로, 새 화면에 .tabnav-unclip(overflow visible)을 쓰지 마세요 — 쓰면 스크롤도 셰브론도 꺼집니다.
  • 아이콘만 남은 버튼의 라벨은 display: none 금지(눈에서만 숨김) — 접근 이름이 title로 바뀝니다(필터 바 트랩과 같은 함정).
  • 다크 모드(2026-10-19) — 비활성 탭 글자 --ds-tab-inactive-fg 는 라이트 neutral-300 · 다크 label-disable(450, 대비 3.1). 다크에서 500 이면 2.36 으로 묻혔다. 탭 바 자체는 면을 칠하지 않아 놓인 면을 따르고, 스크롤 화살표 __arrow · 행 액션 ds-tab-nav-row__actions 면은 bg-container(전 --ds-bw-white 고정). 확인: Playground 「미리보기 테마 → 다크」.

진행 중 (TabNav component — work in progress)

  • 페이지 본문 영역에 자리 잡는 설정 기반(config-driven) 탭 바. Figma .ds-tab-nav 스타일.
  • 호스트가 getTabs / getActive / onSelect 만 넘기면 렌더 — 탭별 슬롯(아이콘·텍스트·dot·badge·disabled)은 있으면 켜지는 on/off 구조.
  • 모든 색·폰트·간격은 .ds-tab-* 토큰에서 옴 (마크업에 인라인 스타일 없음).
  • PR #247에서 신규 추가. 같은 PR에서 이중 밑줄 함정을 공유 파일에서 일원화(함정 참고).
  • ⚠️ 이 문서는 웹 전용입니다. 모바일 앱에서는 탭이 독립 컴포넌트가 아니라 상단 헤더의 일부입니다 — nav.ds-tab-nav를 본문에 두지 말고 AppHeader의 getTabs를 쓰세요(탭 줄). 탭 아이템 스타일(.ds-tab-item)만 양쪽이 공유합니다.

구조 ​

┌─ nav.ds-tab-nav ────────────────────────────────────────────────┐
│  [leadingIcon] [leadingText]  라벨•dot  [trailingText] [trailingIcon] [badge] │
│  └─ .ds-tab-item (각 슬롯은 present일 때만 렌더) ────────────────┘  │
│   활성 탭만 하단 2px 바(.ds-tab-underline) + neutral-900 색       │
└──────────────────────────────────────────────────────────────────┘
  • 탭 단위 확장: 한 탭은 맨몸 라벨일 수도, 아래 슬롯을 자유 조합할 수도 있음 — 각 슬롯은 값이 있을 때만 켜짐.
    • leadingIcon / trailingIcon → 아이콘 레지스트리 SVG (앞/뒤)
    • leadingText / trailingText → 라벨 옆 작은 보조 텍스트 (앞/뒤)
    • updateDot → 라벨 우상단 "새 글/안 읽음" 점 (오렌지)
    • badge → 카운트 pill
    • disabled → 비활성(클릭 무시 + opacity 40%)
  • 활성 상태: 텍스트 neutral-300 → neutral-900, 하단 2px 바 표시.
  • hover·pressed: 회색 배경 틴트(--ds-hover-tint-16 / -30). 모바일 앱 셸 탭은 예외로 이 틴트를 쓰지 않습니다 — 터치 기기에서 :hover가 탭한 뒤에도 남아 선택하지 않은 탭에 회색 박스가 붙은 것처럼 보이기 때문입니다(AppHeader — 배경 틴트 없음).

함정 (double underline) ​

활성 밑줄을 그리는 방법이 두 개 공존한다:

  1. 레거시 .ds-tab-item::after (flex + overflow-x:auto 탭에선 가장자리에서 잘릴 수 있어 불안정)
  2. 공유 컴포넌트가 렌더하는 명시적 .ds-tab-underline span

둘이 겹치면 이중 밑줄이 난다. PR #247에서 공유 파일 한 곳에 다음 규칙으로 일원화:

css
.ds-tab-item.has-underline::after { display: none; }

→ span을 렌더하는 공유 템플릿(tab-nav.html · app-header.html)이 그 탭에 has-underline 을 달아 ::after를 끄고, span 없는 커스텀 nav는 ::after 유지. 이전엔 앱마다 자기 main.css에서 각자 끄던 것을 공유 DS로 흡수.

⚠️ 처음엔 .ds-tab-item:has(.ds-tab-underline)::after 였다. :has() 는 문서 어디서든 DOM 이 바뀔 때마다 브라우저가 문서 전체 스타일을 다시 계산하게 만들어(admin 직원 목록에서 실측) 2026-09-22 에 마크업 클래스로 바꿨다 — DS CSS 에 :has() 를 다시 들이지 말 것. 커스텀 nav 가 span 을 직접 렌더한다면 탭 요소에 has-underline 을 함께 단다.

크기 (size) ​

탭은 2단계 크기예요. tabNav({ size })로 지정하고(기본 md), 각 .ds-tab-item에 size 클래스가 붙어요. size축은 padding·높이·라벨 폰트만 바뀌고 색·언더라인 두께(2px)·hover는 공통이에요.

tokenmin-heightpadding라벨 폰트클래스
md (기본)40px10px 12px--text-b5 (13px).ds-tab-item (modifier 없음)
ml48px12px 16px--text-b4 (14px).ds-tab-item--ml

활성 하단바(.ds-tab-underline)는 nav 하단 구분선과 겹치도록 콘텐츠박스 바닥 + 보더 1px만큼 내려요(md -12px / ml -15px) — size 클래스만 바꾸면 자동 정렬돼요. (활성 탭은 회색 1px선을 2px 검정바가 덮어 한 줄로 보여요)

같은 탭을 md(위) / ml(아래) 로 나란히 둔 비교예요. 탭 바 높이(40 → 48)와 라벨 글자(13 → 14px) 차이를 직접 확인하세요. 둘 다 실제 .ds-tab-* 클래스로 렌더돼요.

하단 구분선 (divider) ​

탭 바 하단의 회색 1px 선(--color-neutral-200)은 DS(nav.ds-tab-nav)가 소유하고, tabNav({ divider })로 켜고 끌 수 있어요(기본 true). 끄면 nav에 .ds-tab-nav--flush가 붙어 선이 사라지고 아래 콘텐츠와 flush로 붙어요 — 탭 바 바로 아래에 자체 상단선을 가진 영역을 둘 때 이중선을 피할 수 있어요.

활성 바는 구분선과 별개예요

활성 탭의 2px 검은 하단바(.ds-tab-underline)는 이 회색 구분선과 다른 요소(활성 표시)예요. 그래서 활성 탭 아래는 1px 회색이 아니라 2px 검정으로 보여요 — divider를 꺼도 활성 바는 그대로 남아요.

좁아질 때 — 셰브론과 버튼 줄 ​

탭이 가로로 넘치면 스크롤바 대신 넘치는 방향에만 원형 < > 가 뜨고, 가장자리에 흰 페이드가 깔려요. 한 번 누르면 보이는 폭의 80%씩 넘어가요. 잘려 있던 탭을 누르면 그 탭이 온전히 보이게 당겨져요(셰브론에 가리지 않게 32px 여유). 모양은 필터 바의 셰브론과 같아요.

탭 바 오른쪽에 기능 버튼(「근무 설정」 등)이 있는 화면은 한 줄로 묶어요.

html
<div class="ds-tab-nav-row">
  <div class="ds-tab-nav-row__tabs" x-data="tabNav({ getTabs, getActive, onSelect, size: 'ml' })">
    <!-- include "../../packages/components/html/tab-nav.html" -->
  </div>
  <div class="ds-tab-nav-row__actions">
    <button class="ds-btn ds-btn--line ds-btn--primary ds-btn--md">
      <span class="ds-ico" x-html="$icon('ic-sliders')"></span>
      <span>근무 설정</span>
    </button>
  </div>
</div>

줄어드는 순서는 버튼이 먼저, 탭 스크롤이 나중이에요. 줄 폭이 「탭 전체 + 라벨 달린 버튼」보다 좁으면 줄에 .is-compact가 붙어 버튼이 32×32 아이콘이 되고(라벨은 툴팁), 그래도 탭이 넘치면 셰브론이 떠요. 판정은 늘 라벨이 다 보이는 원래 폭으로 해서, 줄였다 늘였다 흔들리지 않아요. 실측(근태 현황): 근무 탭(버튼 1개)은 480px부터 아이콘·400px부터 >, 연차 탭(버튼 2개)은 600px부터 아이콘.

예전 방식 — absolute 겹침

버튼을 absolute inset-y-0 right-6으로 탭 바 위에 겹쳐 올리면, 탭 바는 버튼이 있는 줄 모르고 전체 폭을 써서 좁은 화면에서 버튼이 탭 글자를 덮어요. 이 방식은 쓰지 마세요.

호출 패턴 ​

html
<div x-data="tabNav({
  getTabs: () => myTabs,            // () => TabNavItem[]
  getActive: () => activeId,        // () => string
  onSelect: (id) => switchTo(id),   // (id) => void
  size: 'ml',                       // 'md'(기본) | 'ml' — 선택
  divider: false,                   // true(기본, 하단선) | false(선 제거) — 선택
})" class="shrink-0">
  <!-- include "../../packages/components/html/tab-nav.html" -->
</div>

→ partial 한 줄로 nav.ds-tab-nav 전체가 렌더됨. 탭 목록·활성 ID는 호스트 상태에 반응(reactive).

API ​

tabNav(opts: TabNavOptions) Alpine.data factory.

TabNavOptions (모두 optional) ​

Prop타입설명
getTabs() => TabNavItem[]반응형 탭 목록 source
getActive() => string반응형 활성 탭 id source
onSelect(id: string) => void비활성 아닌 탭 클릭 시 id로 호출
size'md' | 'ml'탭 크기. 생략 시 md(40px); ml은 큰 탭(48px). 각 탭에 tabNavSizeClass로 반영
dividerboolean하단 구분선(회색 1px). 생략 시 true; false면 선 제거(flush). nav에 tabNavDividerClass로 반영
fillboolean탭이 줄을 균등 분할(flex: 1 1 0% + 가운데 정렬, 좌우 여백 0). 생략 시 false(라벨 길이만큼). nav에 tabNavFillClass로 반영

TabNavItem (탭 1개) ​

필드타입필수설명
idstring✅onSelect에 넘어가는 고유 id
labelstring✅메인 탭 텍스트
leadingIconstring앞 SVG 아이콘 이름 (레지스트리). 생략 시 숨김
trailingIconstring뒤 SVG 아이콘 이름. 생략 시 숨김
leadingTextstring라벨 앞 보조 텍스트. 생략 시 숨김
trailingTextstring라벨 뒤 보조 텍스트. 생략 시 숨김
updateDotboolean라벨에 "업데이트/안 읽음" 점 표시
badgestring | numberpill 배지 값(예: 카운트). 비거나 생략 시 숨김
disabledboolean클릭 무시 + 흐리게

컴포넌트가 노출하는 메서드 (partial이 사용) ​

멤버설명
tabNavItemsgetTabs() 결과 (getter)
tabNavActiveIdgetActive() 결과 (getter)
tabNavIsActive(id)활성 여부
tabNavSelect(item)disabled면 무시, 아니면 onSelect(item.id)
tabNavIconSvg(name)아이콘 이름 → SVG 문자열
tabNavHasBadge(item)badge가 비어있지 않은지
tabNavSizeClasssize 모디파이어 클래스(""=md / "ds-tab-item--ml"=ml) (getter)
tabNavDividerClass하단 구분선 모디파이어(""=on / "ds-tab-nav--flush"=off) (getter)
tabNavFillClass균등 분할 모디파이어(""=자연폭 / "ds-tab-nav--fill"=균등) (getter)

사용된 토큰 ​

카테고리토큰
Color (text)--color-neutral-300(비활성) · --color-neutral-900(활성) · --color-neutral-400/500(보조 라벨)
Color (accent/bg)--color-orange-500(dot) · --ds-tint-light-20(badge bg) · --ds-hover-tint-16/30(hover/active) · --color-neutral-200(nav 하단선)
Typography--text-b5(13px, md 라벨) · --text-b4(14px, ml 라벨) · --text-b6(12px, 보조 라벨)
Geometrymd: padding 10px 12px · min-height 40px / ml: padding 12px 16px · min-height 48px · underline 2px (탭 전용, .ds-tab-item / .ds-tab-item--ml에 직접 정의)

단일 출처 ​

파트파일
Alpine 컴포넌트packages/components/src/components/TabNav.ts
HTML partialpackages/components/html/tab-nav.html
스타일packages/static/styles/design-system.css (.ds-tab-*)
Alpine 등록tabNav (plugin.ts)

토큰 소유권: color·typography·탭 전용 geometry(padding/min-height/underline)는 DS 소유(.ds-tab-* 클래스). 마크업의 레이아웃(flex·gap)만 Tailwind 유틸. arbitrary [...]·하드코딩 hex 없음.

검토 필요 사항 ​

토큰 체계가 아직 커버 못 해 하드코딩으로 남은 항목 (CSS 주석에 명시됨):

  • 🟡 badge font-size: 10px — 타입 스케일 최하단(b6=12px)보다 작아 매칭 토큰 없음
  • 🟡 dot 5px + 위치 오프셋(top -2px / right -7px) — 마이크로 사이즈, 토큰 없음
  • 🟡 underline bottom: -11px — nav 높이에 맞춘 매직 오프셋
  • ✅ min-height 40px(md) / 48px(ml) — 폼 컨트롤은 PR #247에서 32px로 통일됐지만 .ds-tab-item은 의도적으로 제외(탭 기준은 40px, ml은 48px)
  • 🟡 underline 오프셋 md -12px / ml -15px — nav 하단 구분선(1px)과 겹치도록 탭 높이에 맞춘 매직 오프셋(토큰화 대상)

사용처 ​

목표 & 로드맵 ​

항목현재목표
설정 기반 구조✅ getTabs/getActive/onSelect유지
탭별 슬롯 on/off✅ icon/text/dot/badge/disabled유지
Color 토큰 매핑✅ 완료유지
Typography 토큰 매핑✅ b5(md)/b4(ml)/b6유지
크기 변형 (size)✅ 2단계 md/ml유지
하단 구분선 on/off (divider)✅ DS 소유 + --flush유지
이중 밑줄 일원화✅ .has-underline 공유 규칙 (템플릿이 표시)유지
badge/dot 마이크로 토큰🟡 하드코딩마이크로 사이즈 토큰 정책 결정 후 적용
underline 오프셋 토큰화🟡 md -12px / ml -15px 매직값(구분선과 overlap)nav 높이 토큰과 연동 검토

변경 이력 ​

날짜내용
2026-09-17좁아질 때 대응 — 탭이 넘치면 좌우 셰브론(< >) 자동 · 탭 줄 + 기능 버튼 한 줄 구조 .ds-tab-nav-row(버튼 아이콘화 → 탭 스크롤 순). 스크롤 박스 아래 4px 여유로 활성 밑줄 잘림 해소. 근태 현황·스케줄러 적용(absolute 겹침 제거)
2026-06-30탭 크기 2단계 변형(size) 추가 — md(기본 40px) / ml(48px, .ds-tab-item--ml). tabNav({ size }) 옵션 + tabNavSizeClass getter, <DemoTabNav size="ml"> 미리보기. ml 활성 하단바 오프셋 -14px 재계산
2026-06-30하단 구분선 on/off(divider) 추가 — 회색 1px 선을 markup border-b → DS 소유(nav.ds-tab-nav)로 이전, .ds-tab-nav--flush off 모디파이어 + tabNav({ divider }) 옵션 + tabNavDividerClass getter, <DemoTabNav :divider="false"> 미리보기
2026-06-08TabNav 페이지 신규 작성 + 라이브 미리보기(<DemoTabNav>) 적용 (PR #247 기준)
PR #247TabNav 공유 컴포넌트 신규 (tabNav + tab-nav.html + .ds-tab-* 토큰). 이중 밑줄 :has() 일원화. ds-tab 색/폰트 토큰화