하단 내비게이션 (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>분리형 — 실행 버튼(getAction) 있음 · 직원 앱 「AI 어시스턴트」
한 줄형 — 실행 버튼 없음(getAction 이 null) · 활성 fill + 배지 3
활성 fill 끔 — 활성도 outline (비교용)
배지 150 → badgeMax(99) 초과라 「99+」
disabled(더보기) · hidden(결재) — 하나는 남고 하나는 빠짐
실행 버튼(분리형) · 활성 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.css | Capacitor 네이티브 키보드 전용 상태 |
| 셸 폭 | --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 기본은 선택 유리 |
관련
아이콘
이중 주입 — 레지스트리 또는 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()가 반응형으로 읽히므로 스토어 값을 그대로 연결하면 돼요.
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).
<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)은 화면 바탕색으로 넘깁니다.
<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>export const AI_NAV_ACTION: BottomNavAction = {
id: "ai-help",
label: "AI 어시스턴트", // 화면에 안 보이지만 필수 — aria-label · title
icon: "ic-sparkles",
ariaLabel: "AI 어시스턴트 열기",
};탭 목록은 앱의 config 파일에 둡니다 — 탭 추가·삭제·순서 변경이 이 배열 수정만으로 끝나야 합니다.
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) 아닌 항목을 탭했을 때 |
badgeMax | number | 99 | 숫자 배지가 이 값을 넘으면 "99+" 형태로 |
getAction | () => BottomNavAction | null | — | 탭 오른쪽 실행 버튼(분리형). null 이면 한 줄 탭바 |
onAction | (id: string) => void | — | 실행 버튼을 눌렀을 때 |
BottomNavItem
| 필드 | 타입 | 설명 |
|---|---|---|
id | string | 고유 id. onSelect 인자이자 활성 비교 대상 |
label | string | 아이콘 아래 라벨 |
icon | string? | 레지스트리 아이콘 이름(getIcon) |
iconSvg | string? | raw SVG — icon보다 우선 |
activeIcon | string? | 활성 상태 레지스트리 아이콘 |
activeIconSvg | string? | 활성 상태 raw SVG — activeIcon보다 우선 |
badge | () => number | string | null | undefined | 배지 값. 0·null·"" 면 숨김 |
hidden | () => boolean | 항목을 목록에서 제거(권한·피처플래그) |
disabled | boolean? | 렌더하되 탭 동작 차단 |
BottomNavAction
| 필드 | 타입 | 설명 |
|---|---|---|
id | string | onAction 인자 |
label | string | 이름(필수) — 화면에는 보이지 않고 aria-label·title 로 쓰입니다 |
icon | string | 레지스트리 아이콘 이름. BI 패밀리 그라디언트 원(.ds-bottom-nav__action-orb, 56px) 안에 흰색으로 그려집니다 |
ariaLabel | string? | 스크린 리더용 설명. 생략하면 label |
탭은 화면을 바꾸고, 실행 버튼은 무언가를 엽니다. 그래서 분리형에서는 둘을 다른 물체로 그립니다 — 탭은 흰 캡슐(선택 = 선택 유리, 직원 앱은 회색 바탕 neutral-75), 실행 버튼은 BI 그라디언트 원(56px, 아이콘만). 원의 폭이 고정이라 글자 배율이 커지면 캡슐 쪽이 줄어듭니다.
hidden은 렌더에서 아예 빠지고, disabled는 보이되 눌리지 않습니다 — 권한으로 메뉴를 감출 때는 hidden, "곧 제공" 표시에는 disabled를 쓰세요.
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/BottomNav.ts |
| HTML partial | packages/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없음.