Skip to content

하단 내비게이션 (BottomNav) ​

모바일 하단 탭바의 슬롯(활성 글리프 · 배지 · disabled · hidden · 실행 버튼)을 켜고 끄며 확인하고, 사용 기준과 호출 방법을 찾아보세요. 실행 버튼(getAction)을 주면 탭 캡슐 + 원형 버튼이 떠 있는 분리형으로, 주지 않으면 예전 한 줄형으로 그려집니다. 분리형의 탭 캡슐은 리퀴드 글라스(뒤가 비치는 유리)이고, 선택된 탭 뒤에는 선택 유리가 떠 있어 누르면 제자리에서 부풀고 떼면 스프링으로 누른 탭까지 옮겨 갑니다.

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

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마
본문 영역
캡슐 뒤로 지나가는 내용
실제 .ds-bottom-nav* 클래스로 렌더하고 폭도 앱 셸과 같은 430px 이에요 — 탭을 눌러 전환해 보세요. 활성은 색만 바뀌는 게 아니라 글리프가 outline → solid 로 바뀝니다. 실행 버튼을 켜면 분리형 — 탭은 리퀴드 글라스 캡슐(선택 = 뒤에 떠 있는 선택 유리, 누르고 있으면 부풀고 떼면 스프링으로 옮겨 감), 오른쪽 원은 BI 패밀리 그라디언트이고 내용 위에 떠 있어요. hidden 은 목록에서 아예 빠지고 disabled 는 보이되 눌리지 않아요 — 권한으로 감출 때는 hidden, 「곧 제공」에는 disabled 입니다.
// 탭 목록은 앱 config 파일에 둔다 (employee-app: src/nav-config.ts)
export const BOTTOM_NAV_ITEMS: BottomNavItem[] = [
  { id: "shifts", label: "근무", iconSvg: ICON_SHIFTS, activeIconSvg: ICON_SHIFTS_ACTIVE },
  {
    id: "messenger",
    label: "메신저",
    iconSvg: ICON_MESSENGER,
    activeIconSvg: ICON_MESSENGER_ACTIVE,
    badge: () => Alpine.store("messenger").unreadCount,
  },
  {
    id: "approval",
    label: "결재",
    iconSvg: ICON_APPROVAL,
  },
  {
    id: "more",
    label: "더보기",
    iconSvg: ICON_MORE,
  },
];

// 실행 버튼은 탭 배열에 넣지 않는다 — 화면을 바꾸지 않고 무언가를 연다(활성 상태 없음)
export const AI_NAV_ACTION: BottomNavAction = {
  id: "ai-help",
  label: "AI 어시스턴트",
  icon: "ic-sparkles",
  ariaLabel: "AI 어시스턴트 열기",
};

<!-- 래퍼의 class="contents" 는 필수다. 없으면 flex 자식이 하나 끼어 레이아웃이 깨진다 -->
<div class="contents" x-data="bottomNav({
       getItems: () => BOTTOM_NAV_ITEMS,
       getActive: () => activeSection,
       onSelect: (id) => switchSection(id),
       getAction: () => (canUseAi ? AI_NAV_ACTION : null), // null = 한 줄 탭바
       onAction: () => openAiHelp(),
     })">
  <!-- include "../../packages/components/html/bottom-nav.html" -->
</div>

실행 버튼(분리형) · 활성 fill 아이콘 · 배지 · disabled · 숨김 을 바꾸면 실제 .ds-bottom-nav* 클래스로 렌더된 탭바와 config 코드가 함께 바뀝니다. 분리형에서 탭을 누르고 있으면 선택 유리가 부풀고, 떼면 스프링으로 옮겨 갑니다. 대표 상태 다섯 가지는 「모아보기」 탭에 있습니다.

데모의 한계

아이콘은 employee-app nav-config.ts의 스냅샷이라 앱 config가 바뀌어도 따라오지 않습니다. 하단 safe-area 패딩(env(safe-area-inset-bottom))은 데스크톱 브라우저에서 0이라 눈에 보이지 않고, 네이티브 키보드가 뜰 때 내비가 숨는 동작도 여기서는 재현되지 않습니다. 분리형의 직원 앱 크기 고정(--app-nav-zoom)은 앱 main.css 규칙이라 데모는 DS 정본 크기(아이콘 20 · 라벨 12)로 보입니다. 선택 탭 바탕도 데모는 DS 선택 유리이고, 직원 앱은 neutral-50 평면입니다(2026-10-12).

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

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

마크업

  • 호스트가 x-data="bottomNav({...})"로 감싸고 packages/components/html/bottom-nav.html을 include 합니다. 손으로 짓지 마세요.
  • 활성 클래스는 is-active 입니다. ⚠️ TabNav는 tab-active 로 다릅니다.
  • 탭 목록을 템플릿에 하드코딩하지 마세요. 항목은 getItems()가 주는 배열에서만 옵니다. 앱은 이 배열을 별도 config 파일에 두세요(employee-app은 apps/employee-app/src/nav-config.ts).

⚠️ 트랩 1 — 이름이 비슷한 .ds-tabbar는 전혀 다른 컴포넌트입니다.ds-tabbar는 height:48px + border-bottom + 밑줄 인디케이터를 가진 상단 탭이에요. 하단 탭바가 아닙니다(그리고 저장소 전체에서 사용처 0). 하단 내비를 만들 때 .ds-tabbar를 쓰면 안 됩니다 — 세로 스택도, safe-area도, 배지 슬롯도 없어요.

⚠️ 트랩 2 — 래퍼에 class="contents"가 없으면 레이아웃이 깨집니다 앱 셸 컨테이너가 display:flex; flex-direction:column 인 경우, x-data 래퍼 <div>가 flex 자식으로 끼어들어 .ds-bottom-nav의 flex-shrink:0이 래퍼에 가려집니다. 래퍼에 class="contents"(display:contents)를 붙여 자식 <nav>를 직접 flex 자식으로 올리세요.

⚠️ 트랩 3 — 키보드 대응은 앱 소유입니다 네이티브 키보드가 뜰 때 하단 내비를 숨기는 규칙(body.keyboard-open .ds-bottom-nav { display:none })은 DS가 아니라 앱 main.css 에 있습니다(Capacitor 전용 상태이므로). 클래스명을 바꾸면 이 규칙도 같이 고쳐야 해요 — 안 고치면 키보드 위에 내비가 남습니다.

아이콘

  • iconSvg(raw SVG)가 icon(레지스트리 이름)보다 우선합니다. 활성 상태는 activeIconSvg → activeIcon → 기본 아이콘 순으로 폴백해요.
  • 크기는 래퍼 .ds-bottom-nav__icon이 소유합니다. 주입하는 svg에 w-6 h-6 같은 크기 클래스를 넣지 마세요.
  • 아이콘에 색을 넣지 마세요. currentColor만 쓰고, 색은 .ds-bottom-nav__item / .is-active가 줍니다.
  • 활성용 fill 글리프는 비활성 글리프와 중심을 맞추세요. 어긋나면 탭 전환 때 아이콘이 미세하게 움직입니다(실제로 메신저 아이콘이 1.125 어긋나 있었습니다 — 아이콘 참고).

폭

  • 셸 폭은 --ds-app-shell-max-width(기본 430px)로 오버라이드합니다. max-width를 직접 덮어쓰지 마세요.

실행 버튼(getAction) — 분리형

  • 화면을 바꾸지 않고 무언가를 여는 버튼(직원 앱 「AI 어시스턴트」)은 탭 배열에 넣지 말고 getAction 으로 주세요. 주면 하단 내비가 분리형(.ds-bottom-nav--split)으로 바뀝니다 — 탭은 흰 캡슐(__tabs)로 뜨고, 그 오른쪽에 아이콘만 있는 원형 버튼(__action)이 따로 섭니다. 실행 버튼은 활성 상태가 없습니다.
  • 글자가 없으므로 label 은 aria-label·title 로 쓰입니다. 비우지 마세요.
  • 분리형의 선택 표시는 주황이 아니라 선택 유리입니다. 선택된 탭은 글자·아이콘 neutral-700 이고, 그 뒤에 선택 유리(.ds-bottom-nav__glow — 흰 78% + 윗선 하이라이트 + 아래 안쪽 그늘 + 바깥 그림자, 파셜에 한 장만 있음)가 떠 있습니다. 예전 회색 바탕(neutral-75, __item-body 배경)은 없습니다 — 되살리지 마세요. 주황 계열은 실행 버튼만 씁니다 — 실행 버튼 면은 BI 패밀리 그라디언트(--bg-image-gradient-logo-family, 관리자 AI FAB .ds-fab--brand 와 같은 면)입니다. 한 줄형(실행 버튼 없음)은 예전대로 주황 선택 표시이고 선택 유리는 display:none 입니다.
  • 직원 앱은 선택 유리 대신 회색 평면입니다(2026-10-12 디자이너 결정, 앱 main.css — DS 값은 그대로). .ds-bottom-nav__glow 의 흰 78% · 윗선 하이라이트 · 안쪽 그늘 · 바깥 그림자(들림 .is-lift 그림자 포함) · 윗면 광택(::after)을 모두 걷고 neutral-75 알약 하나만 둡니다. 미끄러짐 · 들림(1.08) · 눌림 · 아이콘 튕김 움직임은 DS 그대로입니다. 앱에서 바꾼 것은 background · box-shadow · ::after 뿐이니, 모양(모서리 · 간격)이나 모션을 앱에서 다시 쓰지 마세요.
  • 선택 유리는 JS 가 놓습니다 — 손으로 위치를 쓰지 마세요. BottomNav.ts 가 활성 탭의 .ds-bottom-nav__item-body 자리를 offsetLeft 로 재서 --ds-bottom-nav-glow-x(translateX) · --ds-bottom-nav-glow-w(폭) 두 변수만 인라인으로 넣습니다. getBoundingClientRect 로 바꾸면 직원 앱처럼 내비에 zoom 을 건 호스트에서 배율만큼 어긋납니다. 활성 변경 · 탭 수 변경 · 캡슐 크기 변경(ResizeObserver — 글자 배율 · 숨겼다 다시 보임) 때 다시 놓고, 첫 배치와 크기 변경은 움직임 없이 놓습니다(.is-ready 가 붙기 전에는 전환 없음 — 0 에서 날아오는 모습 방지).
  • 누름 → 끌기 → 떼기 는 .ds-bottom-nav__tabs 의 pointer 이벤트가 받습니다. 손가락이 닿으면 그 탭이 0.96 으로 눌리고(.is-pressing) 선택 유리는 현재 활성 탭 자리에서 1.08 로 부풀어 뜨며(.is-lift), 누른 채 좌우로 끌면 손가락 아래 탭을 따라가고, 떼는 자리에서 선택됩니다(iOS 26 탭바와 같은 동작). 떼면 유리가 스프링으로 미끄러져 내려앉고, 바뀐 탭의 아이콘이 outline → fill 로 바뀌며 한 번 튕깁니다(.is-pop). 버튼의 @click 은 키보드(Enter · Space) 경로로 남고, pointer 로 고른 직후의 click 은 한 번 무시합니다 — 두 경로를 합치거나 @click 을 지우지 마세요. 실행 버튼(원)은 눌림(0.96)만 있고 선택 유리는 가지 않습니다.
  • 지금 탭을 한 번 더 누르면 맨 위로 — 컴포넌트는 같은 id 로 onSelect 를 다시 부를 뿐이고, 무엇을 할지는 호스트가 정합니다. 직원 앱은 selectBottomTab 이 같은 탭이면 지금 보이는 스크롤러(셸 · data-app-scroll-root 목록)를 부드럽게 맨 위로 올리고(헤더 줄도 같이 펴짐), 다른 탭이면 섹션을 바꿉니다(iOS 탭바와 같은 동작, 2026-10-07). 같은 탭 재선택을 막는 조건을 컴포넌트에 넣지 마세요.
  • 모션 수치는 CSS 변수가 정본입니다. 이동 --ds-bottom-nav-move(450ms) · 곡선 --ds-bottom-nav-spring(linear() 스프링 — 강성 220 · 감쇠 20, 넘침 약 6%; linear() 를 모르는 브라우저는 @supports 폴백 cubic-bezier) · 눌림 0.96 · 들림 1.08 · 아이콘 튕김 380ms. JS 에 ms 를 쓰지 마세요. prefers-reduced-motion 이면 유리는 즉시 옮겨 가고 눌림·튕김이 없습니다.
  • 흐림은 캡슐 한 겹뿐입니다. 캡슐은 흰 66% + blur(20px) saturate(170%) + 유리 테두리(안쪽 흰 선 1px · 윗선 1px)입니다 — Apple HIG 「Materials」의 regular 변형(글자가 많고 밝은 바탕이라 사진·영상 전용 clear 가 아님). 선택 유리에는 backdrop-filter 를 걸지 않습니다(두 겹이면 저사양 안드로이드에서 스크롤이 끊김). prefers-reduced-transparency 면 캡슐이 94% 흰색이 됩니다.
  • 바닥 여백은 --ds-bottom-nav-bottom-gap 입니다(기본 8px + safe-area). 앱이 기기별로 덮어씁니다 — 직원 앱은 iOS 만 홈 인디케이터 영역 안으로 12 들어간 max(8px, safe-area − 12px)(iOS 26 탭바가 인디케이터 바로 위에 뜨는 위치, 2026-10-07)이고 안드로이드는 기본값입니다(3버튼 내비 바가 불투명해 겹치면 가려짐). ⚠️ 내비에 zoom 을 거는 호스트는 배율로 나눈 값을 넘겨야 합니다 — zoom 안에서는 env() 도 배율을 타서 34 가 40.8 로 부풉니다(직원 앱은 늘 ×1.2 로 그려지므로 1.2 로 나눔).
  • 쓸 수 없을 때(권한·조직 설정)는 getAction 이 null 을 돌려주게 하세요 — 꺼진 버튼으로 남기지 않습니다. 그러면 예전 한 줄 탭바로 돌아갑니다.
  • 원은 <button> 이 아니라 안쪽 .ds-bottom-nav__action-orb 가 둥급니다. 버튼에 rounded-* 를 붙이지 마세요(전역 규칙이 4px 로 고정합니다).
  • 분리형은 내용 위에 떠 있습니다(position: absolute). 탭 캡슐은 리퀴드 글라스(흰 66% + 뒤 흐림 blur(20px) saturate(170%) + 유리 테두리)라 스크롤되는 내용이 캡슐 뒤로 흐리게 지나가고, 캡슐·원 바깥 빈 곳은 터치를 아래 내용으로 통과시킵니다. 그래서 호스트가 두 가지를 지켜야 합니다 — ① 셸 컨테이너 position: relative ② 스크롤 끝 여백을 내용 높이 64px + --ds-bottom-nav-bottom-gap 이상(직원 앱: body.app-nav-split 이 --app-scroll-end-gap 을 늘림). 안쪽에 따로 스크롤러가 있는 화면(메신저)은 섹션에 같은 높이를 비우고 그 화면의 바탕색으로 칠하세요. 하단 줄 영역에는 위 24px 부터 화면 바탕색으로 짙어지는 그라데이션(::before)이 깔려 맨 아래 내용이 녹아 사라집니다 — 끝 색은 --ds-bottom-nav-surface(기본 흰색)로, 앱이 화면별 바탕색을 넘겨야 띠가 생기지 않습니다(직원 앱: 셸 회색 · 흰 면 화면·메신저는 흰색).
  • 분리형의 탭 아이콘은 20px(한 줄형 24px), 라벨은 DS 기본 12px(b6) 입니다. 직원 앱은 분리형 내비를 화면 설정과 무관하게 「기본」(1.2배) 크기로 고정합니다 — 앱 main.css 의 --app-nav-zoom 이 body 배율을 되돌리는 반대 배율을 내비에만 걸어(작게 ×1.2 · 기본 ×1 · 크게 ×1.2/1.4) 늘 아이콘 24 · 라벨 14.4 로 보입니다. 한 줄형은 예전처럼 배율을 따릅니다. 직원 앱의 「최소 13px」 규칙에서 하단 내비 라벨만 예외이고, 앱에서 13 으로 올리던 규칙은 되살리지 마세요.
  • 분리형은 내용 높이 64px(위 6 + 원 56 + 여유 2) + 바닥 여백 --ds-bottom-nav-bottom-gap 입니다(한 줄형 60px + safe-area). 하단 고정 배너·토스트를 내비 위에 띄우는 앱 규칙이 있으면 분리형 높이도 따로 주세요(직원 앱: body.app-nav-split). 앱이 내비에 배율을 걸면 64 에 기대는 여백(스크롤 끝 · 배너 · 토스트 · 메신저)에도 같은 배율을 곱하고, 바닥 여백은 그 규칙이 사는 배율 공간으로 나눠 더해야 합니다(직원 앱: 64px * var(--app-nav-zoom) + var(--app-nav-bottom-gap-in-body) — 네 곳이 같은 식).
  • 분리형 여부를 CSS 가 :has() 로 알아내게 하지 마세요(금지). 호스트가 실행 버튼 유무로 body 클래스를 켜고 CSS 가 그 클래스를 읽습니다(직원 앱: x-effect 가 body.app-nav-split 토글).
  • 다크 모드(2026-10-13 · 10-20) — 내비 면 bg-container · 윗선 line-normal, 분리형 캡슐의 유리 --color-bg-glass(다크 neutral-800) 78%, 활성 글자 label-body, 배지 글자 static-white(불변). 캡슐 아래 그라데이션 끝 색 --ds-bottom-nav-surface 는 앱이 화면별로 주는데 고정 흰색(--ds-bw-white)을 넘기면 다크에서 흰 띠가 남는다 — 직원 앱 메신저 탭이 그랬고 bg-container 로 고쳤다(10-20). 정본 기본값도 bg-container. 확인: Playground 「미리보기 테마 → 다크」. 분리형 캡슐(10-20): 유리 면이 다크 화면 바탕(neutral-800)과 같아 경계가 사라졌어요 — 다크에서는 캡슐을 화면보다 한 단계 어둡게(--ds-bottom-nav-capsule-bg = neutral-900 92%) 가라앉히고 가는 흰 잉크 테두리(--ds-ink-white-10)를 둬요. 선택 유리(--ds-bottom-nav-glow-bg)는 neutral-700 으로 캡슐보다 밝아요. 「투명도 줄이기」 설정의 불투명 캡슐도 다크에서 neutral-900(--ds-bottom-nav-capsule-solid). 라이트는 그대로.

진행 중 (BottomNav component — work in progress)

  • 모바일 하단 탭바. 아이콘 위에 라벨을 세로로 쌓고, 하단 safe-area(노치/홈 인디케이터)를 흡수하는 앱 셸 크롬.
  • 항목은 전부 설정 기반(config-driven) — 호스트가 getItems / getActive / onSelect 만 넘기면 렌더돼요. 탭을 더하거나 빼는 일이 배열 수정으로 끝납니다.
  • 모든 색·크기·타이포는 .ds-bottom-nav* 토큰에서 옵니다 (인라인 스타일·하드코딩 hex·!important 없음 — 선택 유리의 위치 변수 두 개만 JS 가 인라인으로 넣습니다).
  • 현재 소비 앱은 employee-app 하나입니다. 두 번째 모바일 앱이 생기기 전까지는 사실상 전용이지만, 정의는 공유 DS에 있습니다.

구조 ​

한 줄형 — 실행 버튼 없음 ​

┌─ nav.ds-bottom-nav ─────────────────────────────────────┐
│  border-top · z-index --ds-z-nav · padding-bottom: env(safe-area-inset-bottom) │
│ ┌─ .ds-bottom-nav__list (flex, px 8) ──────────────────┐ │
│ │ ┌─ .ds-bottom-nav__tabs (flex:1, space-around) ─────┐ │ │
│ │ │  ┌ .ds-bottom-nav__item (flex:1) ───────────────┐ │ │ │
│ │ │  │  .ds-bottom-nav__item-body (세로 스택)        │ │ │ │
│ │ │  │   [.ds-bottom-nav__icon  24×24]              │ │ │ │
│ │ │  │   라벨 (b6 / 12px)                            │ │ │ │
│ │ │  │  [.ds-bottom-nav__badge]  ← 우상단 절대배치   │ │ │ │
│ │ │  └ .is-active → 주황 + fill 아이콘 ────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

분리형 — 실행 버튼 있음 (.ds-bottom-nav--split) ​

┌─ nav.ds-bottom-nav.ds-bottom-nav--split ─────────────────────┐
│  position:absolute · 바탕 투명 · padding 6 12 (--ds-bottom-nav-bottom-gap) │
│  ::before = 위 24px 부터 --ds-bottom-nav-surface 로 짙어지는 그라데이션 │
│  빈 곳은 pointer-events:none (터치가 아래 내용으로 통과)          │
│ ┌─ .ds-bottom-nav__tabs (리퀴드 글라스 캡슐) ─────┐  ┌ __action ┐ │
│ │ 흰 66% + blur(20px) + 유리 테두리 · 모서리 9999 │  │ ( orb )  │ │
│ │ [__glow 선택 유리 — 활성 탭 뒤, JS 가 offsetLeft 로 놓음] │  │  56×56   │ │
│ │ ┌ __item ─┐ ┌ __item ─┐ ┌ __item ─┐ ┌ ─────┐ │  │ BI 패밀리 │ │
│ │ │ ▢ 20px  │ │ ▢       │ │ ▢       │ │ ▢    │ │  │ 그라디언트│ │
│ │ │ 라벨 12 │ │ 라벨    │ │ 라벨    │ │ 라벨 │ │  │ 아이콘 26 │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └──────┘ │  └──────────┘ │
│ │  .is-active → neutral-700 · .is-pressing 0.96 · .is-pop 튕김 │   gap 8     │
│ └────────────────────────────────────────────────┘               │
└──────────────────────────────────────────────────────────────┘

선택 유리(__glow)는 __item-body 의 자리를 그대로 덮어 캡슐과 같은 중심의 곡선입니다 — 양 끝이 완전한 반원(9999px)이고 캡슐 가장자리와의 간격이 위아래·좌우 모두 5px 로 같습니다(위아래 = 버튼 padding 5, 좌우 = 캡슐 padding 3 + 버튼 padding 2). 모서리를 고정값으로 바꾸거나 한쪽 간격만 바꾸면 바깥 곡선과 어긋나 보입니다.

탭을 누르면 이 순서로 움직입니다 — ① 손가락이 닿는 순간 그 탭의 아이콘·글자가 0.96 으로 눌리고 선택 유리는 지금 자리에서 1.08 로 부풀어 뜹니다 ② 누른 채 좌우로 끌면 유리가 손가락 아래 탭을 따라갑니다 ③ 손을 떼면 유리가 스프링(450ms · 넘침 약 6%)으로 누른 탭까지 미끄러져 원래 크기로 내려앉고, 그 탭의 아이콘이 outline → fill 로 바뀌며 한 번 튕깁니다(380ms). 실행 버튼(원)은 화면을 바꾸지 않으므로 유리가 가지 않고 눌림만 있습니다.

앱 소유로 남는 것 ​

DS는 컴포넌트와 스타일만 갖습니다. 아래는 앱이 계속 소유해요.

항목위치이유
탭 목록·아이콘앱 config (nav-config.ts)앱마다 다름
body.keyboard-open 숨김앱 main.cssCapacitor 네이티브 키보드 전용 상태
셸 폭--ds-app-shell-max-width앱 컨테이너와 맞춰야 함
실행 버튼 정의·노출 조건앱 config (AI_NAV_ACTION) + 셸 navAction권한·조직 설정은 앱이 안다 — 못 쓰면 null
분리형 그라데이션 끝 색--ds-bottom-nav-surface (앱이 화면별로)화면 바탕색과 같아야 띠가 안 생김
분리형 스크롤 끝 여백·배너·토스트 높이앱 main.css (body.app-nav-split)내비가 내용 위에 떠 있으므로
분리형 바닥 여백(기기별)--ds-bottom-nav-bottom-gap ← 앱 main.css --app-nav-bottom-gap(iOS 는 홈 인디케이터 안으로 12)플랫폼·배율은 앱이 안다
분리형 크기 고정(--app-nav-zoom)앱 main.css직원 앱 화면 설정 배율은 앱 기능 — 같은 배율로 앱 헤더도 고정
선택 탭 바탕(직원 앱 neutral-75 평면)앱 main.css .ds-bottom-nav--split .ds-bottom-nav__glow디자이너 결정(2026-10-12) — DS 기본은 선택 유리

관련 ​

  • AppHeader — 같은 앱 셸의 상단 크롬
  • TabNav — 페이지 안 밑줄형 상단 탭 (활성 클래스가 tab-active로 다름)
  • SubTab — 상위 탭 아래 하위 탭

아이콘 ​

이중 주입 — 레지스트리 또는 raw SVG ​

레지스트리(icons.ts)에 있는 글리프면 icon으로 이름만 주고, 없거나 기존 화면의 모양을 픽셀 그대로 유지해야 하면 iconSvg로 직접 넘깁니다. employee-app의 4개 탭이 후자예요 — 레지스트리의 ic-clock·ic-message는 모양이 다른 별도 디자인이고 "더보기"용 점 3개는 레지스트리에 아예 없습니다.

활성 상태는 fill 타입 ​

선택된 탭은 색만 바뀌는 게 아니라 글리프가 outline → solid(fill)로 바뀝니다. 형태로도 구분되어 현재 위치가 분명해져요.

상태svg 속성
비활성fill="none" stroke="currentColor" stroke-width="1.5"
활성fill="currentColor" (stroke 없음)

활성 변형은 선택 사항입니다. activeIconSvg/activeIcon을 주지 않은 항목은 활성일 때도 기본 아이콘을 그대로 써요.

⚠️ 중심 정렬 — 실제로 밟은 지뢰 ​

비활성/활성 글리프의 광학 중심이 다르면 탭을 누를 때 아이콘이 흔들립니다. 그리고 한 글리프 안에서 요소끼리 어긋나는 경우도 있어요.

employee-app의 메신저 아이콘이 그랬습니다. 말풍선 원의 중심은 x=11.25인데, 점 3개는 Heroicons 원본 좌표(중심 8.25 · 12.375 · 16.5, 평균 12.375)를 그대로 물려받았습니다. 말풍선만 다른 path로 교체되면서 점이 1.125 오른쪽으로 치우친 상태로 남은 것이죠. 시작점을 M8.625 → M7.5로 내려 세 점의 중심을 7.125 · 11.25 · 15.375(평균 11.25)로 맞췄습니다.

fill 글리프의 구멍은 더 크게. outline에서 stroke-width:1.5로 그린 점(r=0.375)은 실제로 지름 ~2.25로 보이지만, solid에서 같은 r로 구멍을 뚫으면 지름 0.75라 거의 보이지 않습니다. 메신저 solid는 r을 1로, 더보기 solid는 0.75 → 1.5로 키웠어요.

배지 ​

배지는 메신저 전용이 아니라 어느 항목에나 붙습니다. badge()가 반응형으로 읽히므로 스토어 값을 그대로 연결하면 돼요.

ts
badge: () => Alpine.store("messenger").unreadCount,
  • 0 · null · undefined · "" → 숨김
  • 숫자가 badgeMax(기본 99)를 넘으면 "99+"
  • 문자열은 그대로 출력 (예: badge: () => "N")
  • 숫자 크기는 c1(11px) semibold — 앱 헤더 알림 벨 배지와 같은 크기로 통일합니다(2026-10-12, 예전 b4 14px). 배지 높이 16 · 최소 폭 16 · 빨강(hr-red-500) 바탕은 그대로예요. 하단 내비와 헤더 중 한쪽만 바꾸지 마세요 — 같은 숫자 배지가 두 크기로 갈립니다.

사용법 ​

호스트가 x-data로 감싸고 partial을 include 합니다. 래퍼의 class="contents"는 필수예요(위 트랩 2).

html
<div class="contents" x-data="bottomNav({
       getItems: () => navItems,
       getActive: () => activeSection,
       onSelect: (id) => switchSection(id),
     })">
  <!-- include "../../packages/components/html/bottom-nav.html" -->
</div>

실행 버튼을 붙이면 분리형이 됩니다. 버튼은 탭 배열이 아니라 getAction 으로 주고, 쓸 수 없을 때는 null 을 돌려주세요. 분리형은 내용 위에 떠 있으므로 셸 컨테이너는 position: relative 여야 하고, 그라데이션 끝 색(--ds-bottom-nav-surface)은 화면 바탕색으로 넘깁니다.

html
<div class="contents"
     x-effect="document.body.classList.toggle('app-nav-split', !!navAction)"
     x-data="bottomNav({
       getItems: () => navItems,
       getActive: () => activeSection,
       onSelect: (id) => switchSection(id),
       getAction: () => navAction,   // 권한·조직 설정으로 못 쓰면 null → 한 줄형
       onAction: () => openAiHelp(),
     })"
     :style="activeSection === 'messenger' ? '--ds-bottom-nav-surface: var(--color-bg-container)' : ''">
  <!-- include "../../packages/components/html/bottom-nav.html" -->
</div>
ts
export const AI_NAV_ACTION: BottomNavAction = {
  id: "ai-help",
  label: "AI 어시스턴트",      // 화면에 안 보이지만 필수 — aria-label · title
  icon: "ic-sparkles",
  ariaLabel: "AI 어시스턴트 열기",
};

탭 목록은 앱의 config 파일에 둡니다 — 탭 추가·삭제·순서 변경이 이 배열 수정만으로 끝나야 합니다.

ts
export const BOTTOM_NAV_ITEMS: BottomNavItem[] = [
  { id: "shifts", label: "근무", iconSvg: ICON_SHIFTS, activeIconSvg: ICON_SHIFTS_ACTIVE },
  {
    id: "messenger",
    label: "메신저",
    iconSvg: ICON_MESSENGER,
    activeIconSvg: ICON_MESSENGER_ACTIVE,
    badge: () => Alpine.store("messenger").unreadCount,
  },
  // …
];

옵션 ​

bottomNav(options) ​

옵션타입기본값설명
getItems() => BottomNavItem[][]항목 목록. 반응형으로 매 렌더 읽습니다
getActive() => string""활성 항목 id
onSelect(id: string) => void—비활성(disabled) 아닌 항목을 탭했을 때
badgeMaxnumber99숫자 배지가 이 값을 넘으면 "99+" 형태로
getAction() => BottomNavAction | null—탭 오른쪽 실행 버튼(분리형). null 이면 한 줄 탭바
onAction(id: string) => void—실행 버튼을 눌렀을 때

BottomNavItem ​

필드타입설명
idstring고유 id. onSelect 인자이자 활성 비교 대상
labelstring아이콘 아래 라벨
iconstring?레지스트리 아이콘 이름(getIcon)
iconSvgstring?raw SVG — icon보다 우선
activeIconstring?활성 상태 레지스트리 아이콘
activeIconSvgstring?활성 상태 raw SVG — activeIcon보다 우선
badge() => number | string | null | undefined배지 값. 0·null·"" 면 숨김
hidden() => boolean항목을 목록에서 제거(권한·피처플래그)
disabledboolean?렌더하되 탭 동작 차단

BottomNavAction ​

필드타입설명
idstringonAction 인자
labelstring이름(필수) — 화면에는 보이지 않고 aria-label·title 로 쓰입니다
iconstring레지스트리 아이콘 이름. BI 패밀리 그라디언트 원(.ds-bottom-nav__action-orb, 56px) 안에 흰색으로 그려집니다
ariaLabelstring?스크린 리더용 설명. 생략하면 label

탭은 화면을 바꾸고, 실행 버튼은 무언가를 엽니다. 그래서 분리형에서는 둘을 다른 물체로 그립니다 — 탭은 흰 캡슐(선택 = 선택 유리, 직원 앱은 회색 바탕 neutral-75), 실행 버튼은 BI 그라디언트 원(56px, 아이콘만). 원의 폭이 고정이라 글자 배율이 커지면 캡슐 쪽이 줄어듭니다.

hidden은 렌더에서 아예 빠지고, disabled는 보이되 눌리지 않습니다 — 권한으로 메뉴를 감출 때는 hidden, "곧 제공" 표시에는 disabled를 쓰세요.

단일 출처 ​

파트파일
Alpine 컴포넌트packages/components/src/components/BottomNav.ts
HTML partialpackages/components/html/bottom-nav.html
스타일packages/static/styles/design-system.css (.ds-bottom-nav*)
Alpine 등록bottomNav (plugin.ts)
호출처 config 예시apps/employee-app/src/nav-config.ts

토큰 소유권: color·typography·geometry(높이/패딩/아이콘 24px · 분리형 20px · 실행 버튼 원 56px)·배지·분리형 캡슐(리퀴드 글라스 · 흐림 · 그라데이션)·선택 유리(모양 · 모션 수치 --ds-bottom-nav-move / --ds-bottom-nav-spring)는 모두 DS 소유(.ds-bottom-nav*). 앱이 넘기는 것은 그라데이션 끝 색 --ds-bottom-nav-surface 와 바닥 여백 --ds-bottom-nav-bottom-gap 둘입니다. arbitrary [...]·하드코딩 hex·새 !important 없음.