앱 헤더 (AppHeader)
모바일 상단 헤더의 깊이·배경·탭 줄·우측 슬롯을 켜고 끄며 확인하고, 사용 기준과 호출 방법을 찾아보세요.
Alpine 컴포넌트 · Mobile · 진행 중(WIP) · 문서 포맷 시범 적용
Interactive playground
.ds-app-header* 클래스로 렌더해요 — 폭도 앱 셸과 같습니다. 깊이는 별도 플래그가 아니라 onBack 으로 정해집니다 — 넘기면 2depth(백버튼 + 제목 강조)가 돼요. divider 를 켜면 헤더 높이가 1px 늘어납니다.<!-- 래퍼의 class="contents" 는 필수다 -->
<div class="contents" x-data="appHeader({
getLeading: () => todayStr(),
showBell: true,
showAvatar: true,
getAvatarInitial: () => displayName.charAt(0),
onAvatarClick: () => switchSection('more'),
})">
<!-- include "../../packages/components/html/app-header.html" -->
</div>깊이
1depth — 목록. 좌측은 보조 텍스트
2depth — 상세. 백버튼 + 제목 강조
2depth + 탭 줄
배경과 구분선
기본 — 투명 배경, 선 없음
solid — 흰 배경 채움
divider — 하단 1px (높이 +1px)
solid + divider
solid + 탭 줄
nav.ds-tab-nav 를 두지 않습니다. 탭 줄에는 hover 배경 틴트가 없습니다 — 터치 기기에서는 탭한 뒤에도 :hover 가 남아 선택하지 않은 탭에 회색 박스가 붙은 것처럼 보이기 때문이에요. 깊이(1depth · 2depth) · solid · divider · 탭 줄 · 액션 · 벨 · 아바타를 바꾸면 실제 .ds-app-header* 클래스로 렌더된 헤더와 호출 코드가 함께 바뀝니다. 대표 상태 여덟 가지는 「모아보기」 탭에 있습니다.
데모의 한계
알림벨은 실제로는 Alpine 공유 컴포넌트(notificationBell)를 중첩 include 하는 자리라, 여기서는 같은 DOM 구조만 정적으로 재현했습니다 — 드롭다운은 열리지 않습니다. 상단 safe-area 패딩도 데스크톱 브라우저에서는 max(0px, 12px) = 12px로만 보입니다(노치 기기에서 더 커집니다). 데모는 DS 기본값이라, 직원 앱의 덮어쓴 값(상단 = 상태바 + 8 · 버튼 44 고정 · 화면 설정과 무관한 크기 등)은 보이지 않습니다 → 직원 앱 적용값.
규칙 (강제) · 코딩 에이전트용
AppHeader 강제 규칙. 상세는 아래 본문.
⚠️ 트랩 1 — PageHeader와 다른 컴포넌트입니다. 섞어 쓰지 마세요.ds-page-header는 데스크톱 크롬이에요 — 3영역(타이틀바/툴바), 팀 셀렉터, 프로필 드롭다운, 44px 헤더 높이 정렬이 전제입니다. 모바일에 그대로 쓰면 툴바가 넘칩니다. 반대로 .ds-app-header를 데스크톱에 쓰면 safe-area 패딩이 의미 없이 붙어요. 화면 폭이 아니라 "무슨 셸인가"로 고르세요.
마크업
- 호스트가
x-data="appHeader({...})"로 감싸고packages/components/html/app-header.html을 include 합니다. 손으로 짓지 마세요. - 래퍼에
class="contents"가 필요합니다. 앱 셸 컨테이너가display:flex; flex-direction:column이면x-data래퍼<div>가 flex 자식으로 끼어들어 헤더 높이 계산이 어긋납니다.display:contents로 자식<header>를 직접 flex 자식으로 올리세요. - 알림벨은 중첩 include 로 들어갑니다.
htmlInclude()는 재귀 처리하지만 경로 기준(root)은 앱 디렉터리예요 — 그래서 partial 안에서도"../../packages/components/html/notification-bell.html"처럼 앱 기준 상대경로를 씁니다. 컴포넌트 기준 상대경로(./notification-bell.html)로 쓰면 빌드가 파일을 못 찾습니다.
⚠️ 트랩 2 — 아바타는 스토어 경로를 쓰세요. getFileUrl()을 직접 부르지 마세요getAvatarImage를 생략하면 컴포넌트가 스토어의 currentUserProfilePictureUrl(presigned·cache-busted)을 씁니다. PageHeader와 같은 경로이고, 사진을 교체했을 때 refreshProfilePictureUrl() 갱신이 자동으로 반영돼요. getFileUrl()을 직접 호출해 x-effect로 넣으면 그 갱신·캐시 무효화를 우회합니다(이 앱이 실제로 그랬고, 컴포넌트화하면서 걷어냈습니다).
기본형은 배경·하단선 없음, 하단 패딩 0padding: max(env(safe-area-inset-top, 0px), 12px) 16px 0. 상단만 safe-area와 12px 중 큰 값을 갖습니다.
- 초기 구현은
12px 16px 8px+ 흰 배경 + 하단선 1px = 57px였고, 기본형은 지금 48px입니다. - 아래 콘텐츠와의 간격은 콘텐츠가 갖습니다. 헤더에 하단 패딩을 되돌리면 화면마다 간격이 이중으로 붙어요.
- 배경·하단선이 필요하면 CSS를 고치지 말고
solid·divider옵션을 쓰세요 (배경과 구분선). 앱 CSS에서.ds-app-header에background를 덧칠하면 두 변형이 갈라집니다. - 직원 앱은 상단 여백 · 크기를 덮어씁니다(2026-10-12, 앱
main.css— DS 값은 그대로) — 상단은max(safe-area, 12px)가 아니라 상태바 + 8(--app-header-top), 헤더 전체는 화면 설정(작게 · 기본 · 크게)과 무관한 고정 크기(zoom: var(--app-nav-zoom)— 하단 내비 분리형과 같은 방식), 하단 패딩은 4(메신저 1depthsolid+ 탭 줄은 0). 표로 → 직원 앱 적용값.
스크롤 겹침 — 본문이 헤더 뒤로 올라가면 리퀴드 글라스 (스크롤 겹침)
- 스크롤되면 헤더에
.is-scrolled가 붙고, 그 모양은 DS 가 소유합니다: 헤더 뒤의 그라데이션 유리층(::before, 2026-10-08) —blur(var(--ds-app-header-blur))(4px)saturate(170%)+ 면 색(--ds-app-header-surface, 기본 흰색)이 위 82% → 60% 지점 56% → 아래 0% 로 옅어지고 층 자체도 아래가 마스크로 사라져 아래 경계선이 없습니다(직원 앱은 82% → 56% 한 번에, 다 접히면 100% → 70% — 직원 앱 적용값). 층은 헤더보다--ds-app-header-fade(24px) 더 내려와 본문이 그 구간에서 흐려지며 헤더 밑으로 들어갑니다. 맨 위에서는 투명(셸과 이어짐)이고 200ms 로 드러납니다.solid헤더도 스크롤되면 흰 면을 걷고 이 층을 씁니다. 예전의 「면 66% 한 장 + 아래 유리 선」으로 되돌리지 마세요. 앱 CSS 에서.is-scrolled의 배경·흐림을 다시 쓰지 마세요(예전 직원 앱의 「면 90% + blur 8px」 규칙은 2026-10-07 DS 로 옮기며 없앴습니다). .is-scrolled는 JS 가 붙입니다 — 지금 보이는 스크롤러가 맨 위가 아닐 때(직원 앱directives/header-overlay.ts, 셸 컨테이너의x-header-overlay).:has()나scroll-timeline으로 CSS 가 알아내게 하지 마세요.- 겹침은 호스트 몫입니다. 본문이 헤더 뒤로 지나가게 하려면 헤더를 셸 위에 띄우고(
position: absolute) 스크롤러에 헤더 높이(--app-header-h, JS 가 잼 — 탭 줄·safe-area 로 달라짐)만큼 안쪽 여백을 줍니다(직원 앱main.css「상단 헤더 겹침」). ⚠️ 헤더를 흐름 안에 두고 스크롤러를margin-top음수로 끌어올리지 마세요 — 헤더 줄이 접히면 스크롤러가 같이 끌려 올라가 본문이 손가락의 두 배 속도로 움직입니다(2026-10-07 측정). 겹침 없이.is-scrolled만 붙이면 비칠 것이 없어 옅은 면만 보입니다. - 면 색은 화면이 넘깁니다. 셸이 회색이면
--ds-app-header-surface: var(--color-hr-gray-50), 흰 면 화면(결재 상세 · 메신저 ·solid)은 흰색 — 안 넘기면 회색 화면에 흰 띠가 섞입니다.prefers-reduced-transparency면 94% 로 거의 불투명해집니다. - 풀레이어 팝업의 제목 줄은 이 헤더 컴포넌트가 아닙니다(
.modal-full__header— 본문이 따로 스크롤하는 flex 자식이라 뒤로 지나가는 내용이 없음). 거기엔 유리를 붙여도 보이지 않으니 붙이지 마세요. 다만 제목 규격과 세로 위치는 2depth 헤더와 같습니다(2026-10-12) — 2depth 의 두 종류 중 「레이어 팝업형(X)」이기 때문입니다 → 2depth 의 두 종류.
스크롤하면 헤더 줄 접기 — collapseOnScroll (헤더 줄 접기)
- 탭 줄이 있는 화면에서
collapseOnScroll을 켜면.ds-app-header--collapsible이 붙고, 본문을 내리면 헤더 줄(제목 · 아이콘)이 스크롤한 양의 0.6배만큼 위로 접혀 사라지며 다 접히면 탭 줄만 상단에 남습니다(.is-collapsed). 맨 위로 돌아오면 다시 내려옵니다. 시간 기반 애니메이션으로 바꾸지 마세요 — 내용과 따로 움직여 튕겨 나오는 것처럼 보입니다(2026-10-07 실기기 보고로 스크롤 연동으로 바꿈). 탭 줄이 없으면 걸리지 않습니다(접으면 헤더가 통째로 사라지므로). 검색 중에도 걸리지 않습니다. - 2depth 에는 켜지 마세요 — 접히면 뒤로 가기가 사라집니다. 직원 앱은 결재 · 메신저 1depth 에서만 켭니다(근무 · 더보기는 탭이 없어 해당 없음).
- 접힌 양
--ds-app-header-collapse(px) ·--ds-app-header-collapse-ratio(0~1), 다 접힘.is-collapsed, 줄 높이--ds-app-header-row-h는 호스트 JS 가 스크롤마다 넣습니다(.is-scrolled와 같은 계약 — 직원 앱directives/header-overlay.ts). 스냅(반쯤 멈추면 가까운 쪽으로 맞추기)은 두지 않습니다 — 손을 뗀 뒤 화면이 혼자 움직여 「휙 붙고 휙 떨어지는」 것처럼 보였습니다. 펼친 높이(--app-header-h)는 다 펼쳐져 있을 때만 잽니다 — 접히는 동안 다시 재면 반올림 오차로 매 프레임 흔들려 본문이 떨립니다. - 탭 전환처럼 스크롤이 아닌 이유로 접힘이 바뀌면 280ms 로 부드럽게 펴고 접습니다(직원 앱, 2026-10-12) — 그때만 헤더에
.is-collapse-animating이COLLAPSE_ANIMATE_MS(280) 동안 붙어 padding-top · 줄 margin-top · clip-path · opacity 에 전환이 걸립니다. 스크롤 연동 중에는 전환이 없고(손가락을 따라가야 함),prefers-reduced-motion이면 즉시 바뀝니다. - 보이는 목록이 바뀌면 다시 판정합니다.
header-overlay.ts는 모든[data-app-scroll-root]를 ResizeObserver 로 지켜보다가 목록이 보였다 숨는 순간(크기 0 ↔ 값) 접힘을 다시 잽니다. 타이머로 기다리지 마세요 — 라우터 탭이 메신저 화면 교체보다 먼저 바뀌어, 「대화」를 내린 뒤 「연락처」를 누르면 맨 위인 연락처 위에 헤더가 접힌 채 빈칸이 남았습니다(2026-10-12 재현 · 수정). - 직원 앱은 다 접히면 탭 줄 위 상단 여백 8 도
--ds-app-header-collapse-ratio에 맞춰 0 까지 줄어, 탭 줄이 상태바 바로 아래에 붙습니다(2026-10-12). - 모양(줄이 위로 가는 만큼 위에서부터 잘리고 흐려짐)은 DS 가 갖습니다. 줄이 상단 safe-area 여백으로 삐져 올라가지 않게
clip-path로 자르니, 앱에서overflow: hidden으로 바꾸지 마세요(배지가 잘립니다). - 헤더가 줄어드는 것은 호스트 몫입니다. 본문이 헤더 뒤로 지나가는 화면(결재)은 펼친 높이(
--app-header-h)를 그대로 두면 내용과 탭 줄이 함께 움직입니다. 헤더 아래에 붙은 화면(메신저 — 섹션이 화면 높이에 맞고 목록만 스크롤)은 접는 화면일 때 섹션을 헤더 전체 높이(--app-header-h, 탭 줄 포함) 만큼 미리 헤더 뒤로 올리고 목록([data-app-scroll-root]) 맨 위에 같은 만큼 여백을 줘서 결재와 같은 구조로 만듭니다(직원 앱main.css.app-container.app-header-collapsible #section-messenger). 그래서 메신저 목록이 헤더 줄뿐 아니라 탭 줄 뒤로도 지나갑니다(2026-10-12 — 예전엔 헤더 줄 높이만큼만 올려 목록이 탭 줄 아래에서 잘리기만 했음). - 목록 위에 서는 것(연결 상태 띠 · 연락처 그룹 채팅 선택 줄 · 미리보기 차단 안내)에는 훅 클래스
.messenger-above-list를 답니다(messenger-content.html). 그중 보이는 첫 줄이 헤더 높이만큼 내려가고, 그때 목록은 위 여백을 갖지 않습니다.x-show로 숨은 것은:not([style*="display: none"])로 거릅니다(:has()금지). 목록 위에 새 줄을 더하면 이 클래스를 꼭 다세요 — 빠뜨리면 그 줄이 헤더 뒤에 깔립니다. - 안쪽 목록이 스크롤하는 화면은 그 목록에
data-app-scroll-root를 답니다(메신저 대화 · 연락처). 셸이 이 목록의 스크롤로 접힘과.is-scrolled를 판정합니다.
탭 줄 — 웹 탭과 다른 두 가지
- 배경 틴트 없음.
.ds-app-header__tabs안에서는 hover·pressed 배경을 깔지 않습니다(웹 TabNav는--ds-hover-tint-16/30사용). 터치 기기에서:hover가 탭한 뒤에도 남아 선택하지 않은 탭에 회색 박스가 붙은 것처럼 보이기 때문입니다. 선택 피드백은 글자색 + 밑줄이 담당해요.@media (hover: hover)로 가르지 마세요 — 데스크톱 브라우저 검수 화면과 실기기가 갈립니다 (배경 틴트 없음). - 활성 밑줄 오프셋이 1px 다릅니다. 웹 값(
ml-15px)은 nav 하단 1px 선에 겹치라고 내려간 값이라, 선이 없고overflow-y: clip이 걸린 헤더에서는 밑줄 아래가 잘립니다. DS가.ds-app-header__tabs안에서-14px로 보정해 두었으니 앱에서 다시 만지지 마세요.
뒤로 가기 버튼 = iOS 엣지 스와이프의 대상 (data-edge-swipe-back)
- 2depth 헤더의 뒤로(<) 버튼(
.ds-app-header__back)에는data-edge-swipe-back이 붙어 있습니다. 직원 앱은 iOS 네이티브에서만 화면 왼쪽 가장자리 20px 에서 오른쪽으로 밀면 이 버튼을 누른 것과 같게 합니다(utils/edge-swipe-back.ts) — 화면이 손가락을 따라오고, 화면 폭 35% 이상 밀거나 빠르게 튕기면 뒤로 가며 2depth 퇴장 슬라이드가 그 위치에서 이어집니다. 햅틱은 없습니다. - 판정 기준은 「화면 맨 위에 실제로 보이는 뒤로(<) 버튼」입니다. 풀레이어 팝업이어도 헤더가 < 면 가능하고(그 버튼에도
data-edge-swipe-back을 답니다 — 급여명세서 본인 확인), X(닫기)면 불가합니다. 다이얼로그 · 튜토리얼이 그 버튼을 덮고 있으면 동작하지 않습니다. - 「이전 달 · 이전 해」 처럼 모양만 < 인 버튼에는 달지 마세요 — 밀었을 때 달이 바뀝니다. 표식을 지우면 그 화면은 조용히 스와이프가 꺼집니다.
- 세로 스크롤을 막는 touchmove(passive: false)는 뒤로 갈 화면(2depth)이 열려 있을 때만 문서에 붙입니다(
setArmed). 문서 전체에 늘 걸어 두면 WebKit 이 스크롤을 덜 최적화해 탭 첫 화면의 긴 목록(결재 · 메신저)이 끊길 수 있습니다. - 안드로이드는 켜지 않습니다(전체화면 제스처 내비게이션의 시스템 뒤로 제스처와 겹침 — 시스템 뒤로 가기가 이미 앱 뒤로 가기로 이어져 있음). Safari 웹도 브라우저 자체 스와이프와 겹쳐 켜지 않습니다.
헤더 버튼은 유리다 — 뒤로(<) 원 · 오른쪽 아이콘 캡슐 (2026-10-08, iOS 26 글라스 버튼)
- 뒤로(
.ds-app-header__back)는 44 유리 원, 오른쪽 아이콘 버튼(액션 · 알림벨)은 유리 캡슐 하나(.ds-app-header__glass, 높이 44 — 안의 버튼은ds-btn--icon --lg36 + 안쪽 여백 4)에 담깁니다. 크기는--ds-app-header-btn(44) 하나가 뒤로 원 · 캡슐 · 검색칸 · 아바타를 함께 정합니다(2026-10-08, 36 → 44). - 직원 앱에서도 화면에 44 로 그려집니다(2026-10-12). 헤더가 늘 ×1.2 로 그려지므로(크기 고정 zoom) 앱
main.css가 DS 값을 1.2 로 나눠 넘깁니다 —--ds-app-header-btn: calc(44px / 1.2)· 캡슐 안쪽 여백4px / 1.2· 캡슐 안ds-btn--icon ds-btn--lg36px / 1.2· 글리프24px / 1.2. 결과: 뒤로 원 44 · 캡슐 높이 44(안쪽 4) · 캡슐 안 버튼 36 · 글리프 24 · 아바타 44 · 메신저 헤더 검색칸 높이 44. 예전엔 DS 44 가 배율을 타 52.8 로 그려졌습니다. 재질은 하단 내비 캡슐과 같습니다(흰 66% +blur(20px) saturate(170%)+ 안쪽 흰 선 1px · 윗선 1px +--shadow-card). 맨 위에서도 늘 유리입니다.prefers-reduced-transparency면 94%. - 아바타는 캡슐 밖입니다(사진이라 유리에 담지 않음). 크기는 버튼과 같은 44(이니셜 b3). 액션 · 벨이 하나도 없으면 캡슐째 숨습니다(빈 알약 금지).
- 뒤로 버튼은
<button>이라 전역 4px 모서리 강제를 받습니다 — 원은 그 규칙이 읽는--ds-btn-radius: 9999px로 만듭니다.!important나:not()목록 추가로 풀지 마세요. - 새 아이콘 버튼은
getActions에 더하면 캡슐 안에 들어갑니다. 헤더에 따로 유리 버튼을 손으로 만들지 마세요. - 글자 버튼도 같은 캡슐 안에 둘 수 있습니다(2026-10-12) — 항목에
text를 주면 아이콘 대신 그 글자를 캡슐과 같은 높이(44)의 알약으로 그립니다(.ds-app-header__text-action·ds-btn-text--dark --lg· 좌우 12 · 600). 예: 직원 앱 「새로운 공지」 헤더 오른쪽 「모든 공지」. 글자 버튼을 캡슐 밖에 따로 만들지 마세요.
직원 앱 헤더 크기 고정 — 화면 설정과 무관 (2026-10-12, 직원 앱 적용값)
- 직원 앱은
.app-container .ds-app-header에zoom: var(--app-nav-zoom)을 걸어 body 배율을 되돌립니다 — 헤더 안은 화면 설정(작게 · 기본 · 크게)과 무관하게 늘 「기본」(×1.2)으로 그려집니다. 하단 내비 분리형과 같은 방식입니다. - ⚠️ zoom 이 걸린 요소의
offsetHeight는 배율 전 값입니다(Chromium · WebKit 동일 실측). 그래서header-overlay.ts가 잰 헤더 높이 · 줄 높이에 헤더의 zoom 을 곱해--app-header-h· 셸의--ds-app-header-row-h에 넣습니다. 곱하지 않으면 본문 시작 위치 · 메신저 목록 여백이 배율만큼 어긋납니다. - 헤더 안에 들어가는 값(버튼 크기 · 상단 여백 등)은 화면 픽셀 ÷ 1.2 로 적습니다.
env(safe-area-inset-top)도 배율을 타므로 같이 나눕니다(--app-header-top: calc((env(safe-area-inset-top, 0px) + 8px) / 1.2)).
검색 줄 — 유리 검색칸 + 「취소」 (getSearch active, 2026-10-08 — 카카오톡 대화방 검색)
- 검색이 켜지면 헤더 줄 자리를 유리 검색칸(DS 모바일 채팅 검색칸
.ds-chat-search-field— 헤더 버튼과 같은 재질 · 높이 44 알약: 돋보기 · 입력 · 지우기 ⊗, 규칙은 모바일 대화 화면)과 「취소」 글자 버튼(ds-btn-text --lg --dark, 라벨cancelLabel)이 대신합니다. 지우기는 친 글이 있을 때만 보이고 눌러도 포커스가 남습니다. - 제출 버튼은 없습니다 — 키보드의 검색 키(
enterkeyhint="search") · Enter 가onSubmit입니다. 예전 「검색」 solid 버튼과 닫기(X) 아이콘을 되살리지 마세요.submitLabel은 호환용으로만 남아 그려지지 않습니다. - 검색 결과는 헤더가 그리지 않습니다(호출부 몫). 직원 앱 메신저 대화방은 결과를 목록으로 펼치지 않고, 입력줄 자리의 검색 막대(▲▼)로 대화 속 일치 메시지를 짚습니다 → 모바일 대화 화면.
헤더 아이콘 크기 — 캡슐 안은 전부 lg(박스 36 · 글리프 24)
- 알림벨 · 우측 액션(
getActions)은 유리 캡슐(44) 안이라 모두ds-btn--icon ds-btn--lg(박스 36 · 글리프 24)예요(2026-10-08, md 에서 키움). 탭 줄 오른쪽 액션(getTabActions)은 캡슐 밖이라md(박스 30 · 글리프 20) 그대로입니다. 직원 앱은 캡슐 안 값을 1.2 로 나눠 넘겨 화면에서도 박스 36 · 글리프 24 이고, 화면 설정과 무관합니다(위 「헤더 버튼은 유리다」). - 캡슐 안에서 한쪽만 크기를 바꾸면 벨 옆에서 크기·선 굵기가 달라 보입니다(2026-10-02 수정 사례) — 액션과 벨은 늘 같은 크기로 둡니다. 왼쪽 뒤로 가기 버튼은 별개(유리 원 44 · 글리프 24).
- 예전의
.ds-app-header__bell > .relative > button > svg(벨 24 강제) 규칙은 2026-07-31 벨을 DS 버튼으로 옮기면서 삭제됐어요 — 되살리지 마세요. - 다크 모드(2026-10-19) — 뒤로(<) 글자는
--color-label-normal(팔레트hr-gray-800이었을 때 다크 유리 원 안에서 묻혔다), 아바타 원은--color-bg-brand-tint· 이니셜--color-label-brand(다크 주황 22% 틴트 · orange-300). 앱 헤더 안에 팔레트 색을 새로 쓰지 않는다. 확인: Playground 「미리보기 테마 → 다크」. 전체 표: 컴포넌트별 다크 값.
진행 중 (AppHeader component — work in progress)
- 모바일 앱 셸 상단 바. 상단 safe-area(노치)를 흡수하고, 좌측 텍스트 · 액션 버튼 · 알림벨 · 아바타를 갖는 앱 크롬.
- 기본형은 배경·하단선이 없습니다 — 셸 배경과 이어지고 하단 패딩도 0이라 높이 48px(예전 57px에서 축소). 배경(
solid)과 하단선(divider)은 각각 독립으로 켤 수 있어요(배경과 구분선). - 1depth / 2depth 두 형태가 있습니다.
onBack을 넘기면 2depth로, 좌측에 백버튼이 붙고 제목이 강조돼요(깊이). - ⚠️ 탭도 이 컴포넌트가 그립니다. 모바일에서 상단 내비게이션은 헤더 줄 + 탭 줄이 하나예요 — 웹처럼 본문에
nav.ds-tab-nav를 따로 두지 않습니다(탭 줄). - 슬롯은 전부 옵션으로 켜고 끕니다 —
onBack/showBell/showAvatar/getActions. 우측 버튼 추가는 배열에 항목을 더하는 것으로 끝나요. - 모든 색·크기·타이포는
.ds-app-header*토큰에서 옵니다 (인라인 스타일·하드코딩 hex·!important없음). - 스크롤되면 리퀴드 글라스가 됩니다 — 본문이 헤더 뒤로 지나가며 흐리게 비치고, 하단 내비 캡슐과 같은 재질이에요(스크롤 겹침).
- 현재 소비 앱은 employee-app 하나입니다.
PageHeader와의 차이
두 컴포넌트는 이름이 비슷하지만 다른 셸을 위한 것입니다.
.ds-app-header (모바일) | .ds-page-header (데스크톱) | |
|---|---|---|
| 셸 | Capacitor 모바일 앱 | 브라우저 데스크톱 |
| safe-area | 상단 env(safe-area-inset-top) 흡수 | 없음 |
| 구성 | leading 텍스트 · 액션 · 벨 · 아바타 | 타이틀바 + 툴바(팀 셀렉터·프로필 드롭다운·divider) |
| 높이 | 내용에 따름 (패딩 기반) | 44px 정렬 전제 |
| 하단 짝 | BottomNav | LnbSidebar |
모바일 앱에 PageHeader를 넣으면 툴바가 넘치고, 데스크톱에 AppHeader를 넣으면 safe-area 패딩이 무의미하게 붙습니다.
구조
┌─ header.ds-app-header ───────────────────────────────────────┐
│ padding: max(env(safe-area-inset-top), 12px) 16px 0 │
│ 배경 없음 · 하단선 없음 · 총 높이 48px │
│ ┌─ .ds-app-header__row (flex, space-between, gap 8) ───────┐ │
│ │ ┌ .__lead ─────────────┐ .ds-app-header__actions │ │
│ │ │ [.__back 44 유리 원] │ ├ .__glass 유리 캡슐 44 │ │
│ │ │ (2depth) .__leading │ │ [액션 …] [.__bell 알림벨] │ │
│ │ └────────────────────────┘ └ [.__avatar 44 원형] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘좌측 __lead는 백버튼 + 제목 묶음, 우측 __actions는 액션 → 벨 → 아바타 순으로 고정입니다.
앱 소유로 남는 것
| 항목 | 위치 | 이유 |
|---|---|---|
| leading 텍스트 내용 | 앱 (todayStr() 등) | 앱마다 다름 |
| 아바타 탭 동작 | onAvatarClick | 라우팅은 앱 소유 |
| 액션 버튼 목록 | getActions | 화면마다 다름 |
.is-scrolled · .is-collapsed 판정 · 겹침(--app-header-h) | 앱 directives/header-overlay.ts + main.css 「상단 헤더 겹침」 | 스크롤러 구조·헤더 높이는 앱이 안다 — 유리 · 접힘 모양은 DS |
| 유리 면 색 | --ds-app-header-surface (앱이 화면별로) | 화면 바탕색과 같아야 띠가 안 생김 |
| 헤더 크기 고정 · 상단 여백 · 버튼 화면 크기 | 앱 main.css 「헤더 크기 고정」 · 「헤더 상단 여백」 · 「헤더 버튼 크기」 (--app-nav-zoom · --app-header-top) | 직원 앱 화면 설정 배율은 앱 기능 → 직원 앱 적용값 |
| 상태 표시줄 색 | 앱 utils/status-bar-surface.ts — 화면 맨 위 면 색을 읽어 body 배경(--app-status-bar-surface) · theme-color 에 넣고(iOS 26 Safari 는 theme-color 대신 body 배경색으로 칠하므로 body 배경이 정본), 네이티브 앱에는 딤까지 합성한 색 · 아이콘 색 · 웹이 상태 표시줄 뒤에 있는지를 넘긴다(capacitor-init.ts). 스크롤돼 헤더가 유리면 흰색 쪽으로 18% 섞는다 | 고정값이면 셸 회색·흰 면 화면과 갈려 상단에 띠가 보인다(2026-10-07 iPhone 보고). 네이티브 규칙은 → 직원 앱 적용값 · 상태 표시줄 |
관련
- BottomNav — 같은 앱 셸의 하단 크롬
- PageHeader — 데스크톱 크롬 (혼동 주의, 차이)
- NotificationBell —
showBell로 켜지는 공유 벨
깊이 (1depth / 2depth)
onBack을 넘기는지로 갈립니다. 별도 depth 옵션은 없어요 — 뒤로 갈 곳이 있으면 2depth라는 뜻입니다.
| 1depth | 2depth | |
|---|---|---|
| 조건 | onBack 없음 | onBack 지정 (+ showBack 미지정/true) |
| 좌측 | __leading 텍스트만 | __back 버튼 + __leading |
__leading 성격 | 보조 텍스트 (날짜 등) | 화면 제목 |
| 스타일 | b4(14px) · neutral-400 | b3(16px) · semibold · neutral-900 |
| 루트 클래스 | — | .ds-app-header--depth2 |
2depth 의 두 종류 — 뒤로형 · 레이어 팝업형
2depth 헤더는 두 종류입니다(2026-10-12 디자이너 결정).
두 종류의 화면 전체 규격(언제 쓰는지 · 구조 · 여백 · 하단 버튼 · 닫기 · 열고 닫힐 때의 움직임 · iOS 엣지 스와이프)은 페이지 인터랙션 — 모바일 앱에 모았습니다. 이 절은 헤더(제목 줄)만 다룹니다.
| 뒤로형(depth) | 레이어 팝업형 | |
|---|---|---|
| 왼쪽/오른쪽 버튼 | 왼쪽 뒤로(<) — 44 유리 원 | 오른쪽 닫기(X) |
| 그리는 것 | 이 컴포넌트(.ds-app-header--depth2) | 풀레이어 팝업의 제목 줄(직원 앱 .modal-full__header · 공용 .ds-modal--full .ds-modal__header) |
| 제목 | text-b3 · semibold · hr-gray-900 · 한 줄 말줄임 | 같은 규격(.modal-full__title) |
| 화면 설정 | 크기 고정(직원 앱 헤더 zoom) | 크기 고정 — body 배율 안이라 calc(var(--text-b3) * var(--app-nav-zoom)) |
| 세로 위치 | 헤더 상단(--app-header-top) | 같은 위치를 body 배율 좌표로(--app-header-top-in-body) |
- 레이어 팝업 제목은
<div role="heading" aria-level="3" class="modal-full__title">로 씁니다.<h3>에 클래스로 크기를 주면 unlayered 태그 기본값에 밀려 조용히 무시됩니다(타이포그래피). - 직원 앱 풀레이어 팝업 전부가 이 규격입니다 — 휴가 · 연장근무 · 외근 신청, 출퇴근 정정, 서류 제출/요청, 인센티브 등록, 주말 당직 세션 신청, 결재자 지정, 연장근무 · 외근 보고서(두 보고서도
.modal-full__header줄을 갖게 됨). 예전엔 h5(20) · 보고서는 gray-800 이었습니다. - 공용 풀레이어 모달(
.ds-modal--full— 불편 · 오류 신고)의.ds-modal__title도 직원 앱에서 같은 크기 · 굵기 · 한 줄 규칙을 받습니다. 색은 DS 그대로(label-normal) —.ds-modal__title에 색을 얹지 않는 규칙 때문입니다(모달). - 팝업 헤더의 글자 버튼(「도움」)도 크기 고정입니다(
.modal-full__header .ds-btn-text에zoom: var(--app-nav-zoom)). - ⚠️ 아직 맞추지 않은 것 — 닫기(X) 버튼. 팝업의 X 는 여전히 32 투명 버튼이고 화면 설정을 따라 커지고 작아집니다. 뒤로형의 44 유리 원과는 다릅니다(알고 두는 차이).
3depth 이상도 2depth와 같은 형태입니다. 깊이를 세지 않고 "뒤로 갈 곳이 있는가"만 봅니다 — 백버튼 + 제목이면 충분하고, 계층이 깊어져도 헤더 모양은 달라지지 않아요.
아래는 실제 렌더입니다(프로덕션과 동일한 클래스).
1depth — 보조 텍스트(날짜) + 벨 + 아바타
2depth 이상 — 백버튼 + 화면 제목
2depth + 탭 줄 — 하위 화면에 탭이 있는 경우
2depth에서 벨·아바타는 보통 끕니다(showBell: false, showAvatar: false) — 하위 화면에서 프로필로 빠지는 동선은 잘 안 쓰니까요. 위 예시도 꺼둔 상태입니다. 다만 강제는 아니라 필요하면 켤 수 있습니다.
헤더 하나가 목록과 상세를 겸할 때
앱 셸은 헤더 인스턴스가 하나인데 화면은 목록(1depth)과 상세(2depth)를 오갑니다. 이때는 onBack을 항상 넘기고 showBack으로 깊이를 가릅니다 — onBack 유무는 마운트 시점에 굳어 바꿀 수 없기 때문입니다.
<div class="contents" x-data="appHeader({
getLeading: () => headerLeading, <!-- 목록: 날짜 / 상세: 화면 제목 -->
onBack: () => depthBack(),
showBack: () => isDepth, <!-- 여기서 깊이가 갈린다 -->
showBell: () => !isDepth,
showAvatar: () => !isDepth,
getActions: () => headerActions, <!-- 상세 전용 액션(검색·멤버 등) -->
})">제목 강조(--depth2)는 백버튼과 한 몸이라 showBack 결과를 따라갑니다. 목록에서 제목만 커지는 일은 생기지 않습니다.
상세 상태는 어디에 두나
상세를 여는 쪽(섹션/컴포넌트 스코프)과 헤더(앱 셸 스코프)는 서로를 못 봅니다. 상태는 앱의 라우터 스토어에, 뒤로가기 같은 동작은 window 이벤트로 넘기세요. 스토어에 뒤로가기 함수를 담으면 섹션 스코프가 스토어로 새어나가고, 섹션이 둘 이상이면 누구 것인지 추적이 안 됩니다.
탭이 필요한 화면은 getTabs를 넘기면 헤더가 탭 줄까지 그립니다 — 별도 컴포넌트를 놓지 않습니다(탭 줄).
스크롤 겹침 — 그라데이션 유리
기본형 헤더는 맨 위에서 투명해 셸과 이어집니다. 본문을 스크롤하면 헤더에 .is-scrolled 가 붙고, 헤더 뒤에 그라데이션 유리층(::before)이 드러납니다 — blur(var(--ds-app-header-blur))(4px) saturate(170%) 위에 면 색이 위 82% → 60% 지점 56% → 아래 0% 로 옅어지고, 층의 아래쪽은 마스크로 사라져 경계선이 없습니다(2026-10-08 — iOS 26 메신저 앱 헤더를 따름. 예전엔 면 66% 한 장 + 아래 유리 선). 층은 헤더보다 --ds-app-header-fade(24px) 더 내려옵니다. 200ms 로 드러나고, 맨 위로 돌아오면 다시 투명해집니다.
직접 스크롤해 보세요 — 프레임 안을 스크롤하면 본문이 헤더 뒤로 지나가며 .is-scrolled 가 켜집니다. 화면(1depth 목록 · 2depth 대화방 · 대화방 검색)을 바꿀 수 있고, 슬라이더로 헤더 흐림(--ds-app-header-blur, 정본 4px)을 바꿔 비교할 수 있습니다. 흐림 값은 이 데모 안에서만 바뀝니다.
근무 소식
.is-scrolled꺼짐 — 맨 위(투명)프레임 안을 스크롤해 보세요
데모의 한계
직원 앱은 헤더를 늘 1.2배로 그리므로 같은 4px 가 앱에서는 약 5px 로 보입니다. 유리층 그라데이션도 직원 앱은 덮어씁니다(맨 위 12px 100% → 82% → 아래 56% · 다 접히면 100% → 70% — 직원 앱 적용값). 13~14px 글자는 4~5px 흐림이면 이미 읽히지 않아서, 그 위에서 값을 바꿔도 차이가 작게 느껴집니다 — 글자 윤곽이 남으려면 1~2px 입니다. 상단 safe-area 는 데스크톱에서 12px 로만 보입니다(직원 앱은 상태바 + 8 — 브라우저에서는 8).
직원 앱 메신저 대화방 · AI 어시스턴트는 대화 목록을 헤더 뒤까지 올려 말풍선이 이 유리 밑으로 지나갑니다(셸에 app-chat-under-header — 셸 여백을 걷고 목록 위 패딩에 헤더 높이를 더함, 목록의 위쪽 마스크는 끔. 목록에는 data-app-scroll-root 가 붙어 스크롤로 유리층이 켜짐).
역할이 셋으로 나뉩니다.
| 누가 | 무엇을 |
|---|---|
DS (.ds-app-header.is-scrolled) | 유리의 모양 — 그라데이션 · 흐림 · 아래로 사라지는 마스크 · 전환 시간 · prefers-reduced-transparency(94%). 직원 앱은 그라데이션 색 단계만 덮어씁니다(흐림 · 마스크 · 투명도 줄이기는 DS 그대로) |
앱 JS (직원 앱 directives/header-overlay.ts) | .is-scrolled 판정(지금 보이는 스크롤러가 맨 위가 아닐 때)과 헤더 높이 --app-header-h 측정 |
앱 CSS (직원 앱 main.css 「상단 헤더 겹침」) | 본문이 헤더 뒤로 지나가게 하는 겹침(헤더 absolute + 스크롤러 padding-top)과 화면별 면 색 --ds-app-header-surface |
/* 앱 — 셸 회색 화면 · 흰 면 화면 · solid 변형이 각각 면 색을 넘긴다 */
.app-container .ds-app-header { --ds-app-header-surface: var(--color-bg-alternative); }
.app-container.app-surface-white .ds-app-header,
.app-container .ds-app-header--solid { --ds-app-header-surface: var(--color-bg-container); }적용 범위는 이 컴포넌트를 쓰는 모든 화면입니다 — 1depth(탭 줄 포함) · 2depth(뒤로 가기) · AI 어시스턴트 페이지 · 메신저 대화방. 풀레이어 팝업의 제목 줄(.modal-full__header)은 이 컴포넌트가 아니고 본문이 따로 스크롤하는 flex 자식이라 뒤로 지나가는 내용이 없습니다 — 유리를 붙여도 보이지 않습니다. 유리는 대상이 아니지만 제목 규격 · 세로 위치는 2depth 헤더와 같습니다 → 2depth 의 두 종류.
스크롤하면 헤더 줄 접기
탭 줄이 있는 화면은 내용을 내릴 때 헤더 줄이 자리를 차지할 이유가 없습니다. collapseOnScroll 을 켜면 본문을 내리는 만큼(0.6배 속도로) 헤더 줄(날짜 · 알림 · 아바타)이 위로 접혀 사라지고, 다 접히면 탭 줄만 상단에 붙어 남습니다. 애니메이션이 아니라 스크롤 연동이라 내용 · 탭 줄 · 헤더 줄이 한 덩어리로 움직입니다. 맨 위로 돌아오면 헤더 줄이 다시 내려옵니다.
<div class="contents" x-data="appHeader({
getTabs: () => headerTabs,
collapseOnScroll: () => !isDepth && (activeSection === 'approval' || activeSection === 'messenger'),
…
})">| 누가 | 무엇을 |
|---|---|
DS (.ds-app-header--collapsible.is-collapsed .ds-app-header__row) | 줄이 접힌 양(--ds-app-header-collapse)만큼 위로 가며 같은 양만큼 위에서부터 잘리고 비율만큼 흐려짐 — 스크롤 연동 |
앱 JS (직원 앱 directives/header-overlay.ts) | 스크롤마다 접힌 양(스크롤 × 0.6) · 비율, 다 접힘, 줄 높이 측정(헤더 zoom 을 곱함), 셸에 .app-header-collapsible — 스냅 없음. [data-app-scroll-root] 를 ResizeObserver 로 지켜 보이는 목록이 바뀌면 다시 판정하고, 그때만 .is-collapse-animating(280ms) |
앱 CSS (직원 앱 main.css) | 헤더 아래에 붙은 화면(메신저)을 헤더 전체 높이(탭 줄 포함)만큼 헤더 뒤로 올리고 목록에 같은 여백(.messenger-above-list 가 보이면 그 줄이 대신 내려감) · 탭 줄 위 상단 여백 8 을 접힘 비율만큼 0 으로 · 비스크롤 전환 280ms |
| 화면(직원 앱) | 접힘 | 스크롤하는 곳 |
|---|---|---|
| 결재 | 켬 | 셸 스크롤러 — 본문이 헤더 뒤로 지나가 유리가 비침 |
| 메신저 | 켬 | 대화 · 연락처 목록(data-app-scroll-root) — 목록이 헤더 전체(탭 줄 포함) 뒤로 지나감. 탭을 바꾸면 그 목록 기준으로 접힘을 다시 판정(280ms 전환) |
| 근무 · 더보기 | 해당 없음(탭 줄 없음) | 셸 스크롤러 |
| 2depth(근무 기록 등) | 끔 — 뒤로 가기가 사라지면 안 됨 | — |
직원 앱 적용값 — DS 기본값과 다른 것
직원 앱(현재 유일한 소비 앱)은 아래 값을 앱 main.css 에서 덮어씁니다(2026-10-12 디자이너 결정, + directives/header-overlay.ts). design-system.css 의 .ds-app-header* 값은 바뀌지 않았습니다. 화면 수치는 직원 앱 「기본」 화면 설정(body 배율 1.2) 기준입니다.
| 항목 | DS 기본값 | 직원 앱 |
|---|---|---|
| 헤더 크기 | 호스트 배율을 그대로 탐 | 화면 설정과 무관하게 고정 — .app-container .ds-app-header { zoom: var(--app-nav-zoom) }(늘 ×1.2). 높이 측정은 zoom 을 곱해 --app-header-h · --ds-app-header-row-h 에 넣음 |
| 버튼 크기 | --ds-app-header-btn 44 (배율 1.2 면 52.8 로 보임) | 화면에 44 — 뒤로 원 44 · 캡슐 높이 44(안쪽 4) · 캡슐 안 아이콘 버튼 36 · 글리프 24 · 아바타 44 · 메신저 헤더 검색칸 44 (DS 값을 ÷1.2 로 넘김) |
| 상단 여백 | max(env(safe-area-inset-top), 12px) | 상태바 + 8 (--app-header-top: calc((env(safe-area-inset-top, 0px) + 8px) / 1.2)). 상태바 없는 브라우저는 8. 예전엔 기기에서 12 는 안 쓰이고 safe-area 가 배율을 타 상태바 밑에 9~12px 빈칸이 남았음 |
| 상단 여백 — 접는 헤더 | — | 탭 줄 위 8 이 --ds-app-header-collapse-ratio 만큼 0 까지 줄어, 다 접히면 탭 줄이 상태바 바로 아래 |
| 하단 패딩 | 0 (solid/divider + 탭 없음이면 8) | 4 — 메신저 1depth(solid + 대화 · 연락처 탭 줄)는 0 (탭 밑줄이 본문에 바로 붙게) |
스크롤 유리층(::before) | 82% → (60%) 56% → 0% | 맨 위 12px 100% → 82%, 그 아래 56% 까지 고르게. 맨 위 가장자리를 불투명하게 시작하는 이유: 휴대폰 브라우저(Safari)는 상태 표시줄 띠를 불투명 단색으로 칠해 웹이 그 뒤로 그릴 수 없어, 반투명이면 밑 내용이 비쳐 띠와 경계가 생긴다. 다 접혔을 때(.is-collapsed, 탭 줄만)는 100% → 70%. 흐림 · 아래쪽 마스크는 DS 그대로이고, prefers-reduced-transparency 에서는 DS 값 유지(덮어쓰기가 @media not (prefers-reduced-transparency: reduce) 안) |
| 비스크롤 접힘 변경 | — | 탭 전환 등은 .is-collapse-animating 280ms 전환(스크롤 연동 중에는 없음 · 모션 줄이기면 없음) |
| 레이어 팝업 제목 줄 | 대상 아님 | 2depth 제목 규격 · 헤더 상단 위치(--app-header-top-in-body)에 맞춤 → 2depth 의 두 종류 |
| 상태 표시줄 | 대상 아님 | 투명 — 웹(헤더 · 화면 맨 위 면 · 딤)이 그 뒤까지 그린다. 아이콘 색만 맨 위 면 밝기로 바꾼다 → 상태 표시줄 |
--app-header-top-in-body 는 --app-header-top × --app-nav-zoom — 헤더 상단과 같은 위치를 body 배율 좌표로 옮긴 값이고, 풀레이어 팝업 제목 줄(.modal-full__header · .ds-modal--full .ds-modal__header)이 이 값으로 정렬합니다.
본문 위 간격. 헤더(투명 기본형)의 하단 4 + 본문 위 여백 4(pt-1) = 8 — 근무 · 더보기 1depth 가 같습니다(2026-10-12, 더보기 pt-4 → pt-1).
상태 표시줄 — 헤더와 한 면으로
iOS · 안드로이드 앱 모두 웹이 상태 표시줄 뒤까지 그리고, 상태 표시줄 자체는 투명합니다(2026-10-12 — 상태 표시줄 띠와 헤더가 끊겨 보인다는 디자이너 보고). 헤더는 top: 0 에서 시작해 상단 여백(--app-header-top)으로 상태바 높이를 비우므로, 헤더의 바탕(투명이면 셸 면 · 스크롤되면 유리층 ::before)이 그대로 상태 표시줄 뒤로 이어집니다. 풀레이어 팝업(.modal-sheet--full · .ds-modal--full)은 흰 시트가 padding-top: env(safe-area-inset-top) 으로 상태바 뒤까지 덮고, 바텀시트 · 다이얼로그는 딤이 화면 맨 위까지 덮어 상태 표시줄도 같이 어두워집니다.
| 규칙 | 값 |
|---|---|
| 상태 표시줄 높이 | 숫자로 박지 않는다 — env(safe-area-inset-top) 만 쓴다. 안드로이드에서 웹이 상태 표시줄 아래로 내려간 경우(아래)는 이 값이 0 이라 여백이 겹치지 않는다 |
| 네이티브 띠 색 | 투명(#00000000). 웹이 상태 표시줄 아래에 있을 때(env(safe-area-inset-top) = 0 — 안드로이드 WebView 139 이하)만 맨 위 면 + 딤 합성색으로 칠한다(Android 14 이하). Android 15+ 는 시스템이 칠하기를 막아 창 바탕(흰색)이 보인다 |
| 아이콘 색 | 맨 위 합성색의 휘도 > 0.179 면 어두운 아이콘(Capacitor Style.Light), 아니면 흰 아이콘(Style.Dark) — 검정 · 흰색 대비가 같아지는 경계. 딤 50% 위(#808080)도 어두운 아이콘 |
| 다크 모드 | 지원하지 않음 — 웹에 다크 CSS 가 없어 네이티브도 라이트로 고정(iOS UIUserInterfaceStyle = Light · 안드로이드 Theme.AppCompat.Light.NoActionBar · SystemBars.style = LIGHT) |
| 첫 실행 | 네이티브 시작 값부터 투명 + 어두운 아이콘(StatusBar.style = LIGHT · 테마 windowLightStatusBar), 안드로이드는 SystemBars.initialViewportFitValueHint = "cover" 로 첫 프레임부터 웹을 상태 표시줄 뒤로 편다 |
네이티브 설정(capacitor.config.ts · Info.plist · styles.xml)은 앱 재빌드로만 반영됩니다 — OTA(웹 번들) 업데이트로는 바뀌지 않습니다.
배경과 구분선
배경(solid)과 하단선(divider)은 서로 독립적인 on/off 축입니다. 넷 중 아무 조합이나 가능하고, 기본형은 둘 다 없음이에요.
| 조합 | 옵션 | 높이 (탭 없음) | 높이 (탭 있음) | 쓰임 |
|---|---|---|---|---|
| 기본형 | — | 48px | 48px + 탭 줄 | 셸 배경과 이어져 화면의 일부처럼 읽힘 |
| 배경만 | solid | 56px | 48px + 탭 줄 | 콘텐츠 위에 얹힌 면으로 읽힘 |
| 선만 | divider | 57px | 48px + 탭 줄 + 1 | 배경 없이 경계만 필요할 때 |
| 배경+선 | 둘 다 | 57px | 48px + 탭 줄 + 1 | 가장 또렷한 분리 |
하단 여백은 조건부입니다
배경이나 선이 있으면 헤더가 하나의 면이 되므로, 콘텐츠와의 하단 여백 8px을 헤더가 직접 갖습니다. 기본형(투명)은 셸과 이어지므로 그 여백이 콘텐츠 몫이라 0이고요.
단 탭 줄이 있으면 붙지 않습니다 — 탭이 그 아래를 채우기 때문입니다. 8px을 더하면 탭과 콘텐츠 사이가 벌어져요. 조건은 이렇습니다:
(solid 또는 divider) AND 탭 줄 없음 → padding-bottom: 8px판정은 컴포넌트(appHeaderRootClass)가 하고 CSS는 결과 클래스(--pad-bottom)만 받습니다. :has()에 기대지 않으려는 의도예요.
참고: 초기 구현의 57px이 정확히
divider+ 하단 8px 조합입니다. 처음부터 헤더는 "선 있는 변형"이었고, 그게 기본값처럼 굳어 있었던 셈이에요.⚠️
divider는border-bottom이라 높이에 1px이 더 붙습니다. 헤더 높이를 계산에 쓰는 곳이 있으면 감안하세요.
기본형 — 배경·선 없음 (48px)
배경만 — solid: true
선만 — divider: true
배경 + 선 — 둘 다 (57px)
배경 + 탭 줄 — 탭이 있으면 하단 8px이 붙지 않습니다
배경이 필요할 때 앱 CSS에서 .ds-app-header에 background를 덧칠하지 마세요. 두 변형이 갈라져 나중에 어느 화면이 어떤 상태인지 추적이 안 됩니다 — 옵션으로만 켜세요.
화면마다 다르면 함수로 넘기세요
앱 셸(employee-app)처럼 헤더 인스턴스 하나가 여러 섹션을 도는 구조에서는 solid·divider를 boolean이 아니라 () => boolean으로 넘깁니다. x-data 표현식은 마운트 시 한 번만 평가되므로, boolean을 넣으면 첫 화면의 값이 그대로 굳어 섹션을 바꿔도 배경이 따라오지 않습니다.
<!-- 메신저 섹션에서만 배경을 깐다(본문 맨 위가 흰 서브탭 바라 회색 띠가 남지 않도록) -->
<div class="contents" x-data="appHeader({
solid: () => activeSection === 'messenger',
})">판단 기준은 본문 맨 위가 무슨 면인가입니다 — 흰 면(서브탭 바·카드)으로 시작하면 solid로 이어 붙이고, 회색 셸이 그대로 이어지면 기본형(투명)을 둡니다.
탭 줄
⚠️ 모바일에서 상단 내비게이션은 헤더와 탭이 한 컴포넌트입니다. 웹처럼 본문에 nav.ds-tab-nav를 따로 두지 마세요 — getTabs를 넘기면 헤더 줄 아래에 탭 줄이 붙습니다. 비우거나 생략하면 탭 줄 자체가 렌더되지 않습니다.
┌─ .ds-app-header ─────────────────┐
│ ‹ 연차 상세 🔔 (A) │ ← __row (헤더 줄)
│ 연차 근무 인센티브 서류 │ ← __tabs (탭 줄, getTabs 있을 때만)
└──────────────────────────────────┘appHeader({
getLeading: () => '결재',
getTabs: () => [
{ id: 'leave', label: '연차' },
{ id: 'work', label: '근무', badge: 2 },
],
getActiveTab: () => activeTab,
onTabSelect: (id) => switchTab(id),
})AppHeaderTab | 타입 | 설명 |
|---|---|---|
id | string | onTabSelect 인자이자 활성 비교 대상 |
label | string | 탭 라벨 |
badge | number | string | null | 후행 카운트. 0·null·"" 면 숨김 |
updateDot | boolean? | 라벨 오른쪽 위 「새로 생긴 것이 있다」 점(.ds-tab-dot) — 웹 TabNav · SubTab 의 updateDot 과 같다. 직원 앱 「신청·결재 현황」 탭이 안 본 반려에 쓴다 |
disabled | boolean? | 렌더하되 선택 차단 |
아이템 스타일은 웹 TabNav와 공유합니다 — .ds-tab-item + .ds-tab-item--ml(48px)을 그대로 씁니다. 형태를 웹과 맞추기 위해서예요.
- ⚠️ 활성 클래스는
tab-active입니다.is-active가 아니에요(BottomNav와 다릅니다 — 그쪽은is-active). - 사이즈는 현재
ml고정입니다. 모바일 전용 사이즈 베리에이션이 정해지면 여기에 축을 추가합니다. - 가로 스크롤은
.ds-app-header__tabs가 소유하며, 활성 밑줄이 박스를 넘어 세로 스크롤을 만드는 문제는overflow-y: clip으로 DS 안에서 막았습니다. 앱 CSS에 클립 보정을 따로 넣지 마세요(웹은 반대로.tabnav-unclip으로 클립을 푸는 opt-in 보정을 쓰는데, 그 규칙을 여기 복사하면 세로 스크롤이 되살아납니다). - 활성 밑줄 위치도 웹과 다릅니다. 웹 탭의 기본 오프셋(
ml-15px)은nav.ds-tab-nav의 하단 1px 선 위에 겹치라고 1px 더 내려간 값이에요. 헤더 탭 줄엔 그 선이 없고overflow-y: clip이 걸려 있어 그대로 두면 2px 밑줄의 아래가 잘립니다(실측2px → ~1.2px)..ds-app-header__tabs안에서 1px 올려(-14px) DS가 보정해 두었으니 앱에서 다시 만지지 마세요.
배경 틴트 없음
모바일 탭에는 hover·pressed 배경 틴트를 깔지 않습니다. 웹 TabNav는 --ds-hover-tint-16/30으로 회색 틴트를 쓰지만, .ds-app-header__tabs 안에서는 배경이 항상 투명입니다.
터치 기기에서 :hover는 탭한 뒤에도 다음 탭을 누를 때까지 남습니다(iOS·Android 공통). 그래서 틴트를 그대로 두면 선택하지도 않은 탭에 회색 박스가 붙어 있는 것처럼 보여요. 모바일에서 선택 피드백은 글자색 + 하단 밑줄이 이미 담당하므로 배경은 아예 뺐습니다.
@media (hover: hover)로 가르지 않았습니다
그 방식이면 데스크톱 브라우저로 앱을 열었을 때(개발·검수 환경) 여전히 틴트가 보여 화면이 두 갈래가 됩니다. 앱 셸 탭은 어디서 열든 "배경 없음" 하나예요. 웹 탭은 그대로 틴트를 씁니다 — 이 규칙은 .ds-app-header__tabs 안쪽에만 적용됩니다.
사용법
<div class="contents" x-data="appHeader({
getLeading: () => todayStr(),
showBell: true,
showAvatar: true,
getAvatarInitial: () => displayName.charAt(0),
onAvatarClick: () => switchSection('more'),
})">
<!-- include "../../packages/components/html/app-header.html" -->
</div>우측에 버튼을 더하려면 getActions만 넘기면 됩니다 — partial은 손대지 않습니다.
getActions: () => [
{ id: 'search', icon: 'ic-search', label: '검색', onClick: openSearch },
{ id: 'filter', icon: 'ic-filter', label: '필터', onClick: openFilter,
hidden: () => !canFilter },
]옵션
appHeader(options)
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
getLeading | () => string | "" | 좌측 텍스트. 1depth면 보조 텍스트, 2depth면 화면 제목 |
onBack | () => void | — | 지정하면 2depth — 좌측 백버튼 + 제목 강조 (깊이) |
showBack | boolean | (() => boolean) | true | onBack이 있어도 false면 그 화면만 1depth. 헤더 하나가 목록·상세를 겸할 때 (깊이) |
backLabel | string | "뒤로" | 백버튼 접근성 이름 |
solid | boolean | (() => boolean) | false | 흰 배경 채움 (배경과 구분선). 화면마다 다르면 함수로 |
divider | boolean | (() => boolean) | false | 하단 1px 선. 켜면 높이 +1px. 함수 가능 |
collapseOnScroll | boolean | () => boolean | false | 스크롤하면 헤더 줄을 접고 탭 줄만 남김(탭 줄이 있을 때만). 판정은 호스트 JS(.is-collapsed) — 헤더 줄 접기 |
showBell | boolean | (() => boolean) | false | 공유 알림벨 렌더. 화면마다 다르면 함수로 |
onBellClick | () => void | — | 넘기면 드롭다운을 열지 않고 이 핸들러만 호출 — 알림을 2depth 화면으로 여는 모바일용 (알림 벨) |
showAvatar | boolean | (() => boolean) | false | 아바타 렌더. 화면마다 다르면 함수로 |
getAvatarImage | () => string | null | — | 이미지 URL 오버라이드. 생략 권장 (아래 참고) |
getAvatarInitial | () => string | 스토어 사용자명 첫 글자 | 이미지 없을 때 폴백 이니셜 |
onAvatarClick | () => void | — | 아바타 탭 |
getActions | () => AppHeaderAction[] | [] | 벨 앞에 놓일 아이콘 버튼들 |
getTabs | () => AppHeaderTab[] | [] | 탭 줄 — 항목이 있으면 헤더 아래에 렌더 (탭 줄) |
getActiveTab | () => string | "" | 활성 탭 id |
onTabSelect | (id: string) => void | — | 탭 선택 |
AppHeaderAction
| 필드 | 타입 | 설명 |
|---|---|---|
id | string | 고유 id. aria-label 폴백이기도 함 |
icon | string? | 레지스트리 아이콘 이름 |
iconSvg | string? | raw SVG — icon보다 우선 |
label | string? | 접근성 이름. 없으면 id |
onClick | () => void | 클릭 핸들러 |
hidden | () => boolean | 버튼을 목록에서 제거 |
아바타
getAvatarImage를 생략하면 스토어의 currentUserProfilePictureUrl을 씁니다. 이게 기본이자 권장 경로예요.
- presigned URL이고 cache-busted 입니다
- 사진 교체 시
refreshProfilePictureUrl()갱신이 자동 반영됩니다 - 이미지 404면
@error로 이니셜 폴백합니다 (PageHeader와 동일 처리)
이니셜은 getAvatarInitial을 주면 그걸 쓰고, 없으면 스토어 사용자명 첫 글자를 씁니다. 앱이 자체 표시명 규칙(displayName)을 가지면 넘겨 주세요.
알림 벨
showBell을 켜면 공유 NotificationBell이 붙고, 누르면 드롭다운이 열립니다. 데스크톱 기준 동작이에요.
모바일에서는 이 팝오버가 어차피 화면을 거의 다 덮습니다. 그래서 알림을 2depth 화면으로 여는 앱은 onBellClick을 넘기세요 — 드롭다운을 렌더하지 않고 그 핸들러만 부릅니다.
<div class="contents" x-data="appHeader({
onBack: () => depthBack(),
showBack: () => isDepth,
showBell: () => !isDepth,
onBellClick: () => openNotifications(), <!-- 드롭다운 대신 2depth 진입 -->
})">- 미읽음 배지는 두 모드가 공유합니다. 어느 쪽이든 같은
notificationBell스코프 안에서 렌더되므로 카운트 폴링이 그대로 살아 있어요. - 벨 숫자 배지 크기는 c1(11px) bold — 하단 내비 배지와 같은 크기로 통일합니다(2026-10-12, 예전 b6 12px). 높이 16 · 최소 폭 16 · 빨강 바탕. 한쪽만 바꾸면 같은 숫자 배지가 두 크기로 갈립니다.
- 여는 화면 쪽은 앱 몫입니다 — 헤더의 백버튼·제목·하단 내비 숨김은 깊이 메커니즘이 이미 처리하므로, 앱은
depth만 세우면 됩니다. - ⚠️ 그 depth의 주인이 셸이라면 뒤로가기를 window 이벤트로 돌리지 마세요. 섹션이 듣고 있는
app-depth-back을 쏘면 엉뚱한 섹션이 자기 뒤로가기를 실행합니다. 셸이 연 화면은 셸이 직접 닫습니다.
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/AppHeader.ts |
| HTML partial | packages/components/html/app-header.html |
| 스타일 | packages/static/styles/design-system.css (.ds-app-header*) |
| Alpine 등록 | appHeader (plugin.ts) |
토큰 소유권: color·typography·geometry(아바타·버튼
--ds-app-header-btn·gap 8px·safe-area 패딩)는 모두 DS 소유(.ds-app-header*). 직원 앱이 덮어쓰는 값은 직원 앱 적용값. arbitrary[...]·하드코딩 hex·새!important없음.