# Tabs 같은 자리에서 여러 화면을 갈아 보여주는 탐색 컴포넌트입니다. > 원본: `03_ASSET_GUIDE/catalog/tabs.js` · CSS: `03_ASSET_GUIDE/styles/elements/tabs.css` > kind `component` · category `navigation` · status `stable` · specSource `measured` --- ## 1. 구성 가로 줄인 **List** 와 그 아래 **Panel** 두 부분입니다. List 안의 각 **Trigger** 는 라벨, 선택적인 개수, 그리고 선택됐을 때만 보이는 2px **Indicator** 를 가집니다. ``` ┌──────────────────────────────────────────────┐ │ 전체 128 │ 진행중 │ 완료 │ ← ① List │ ▔▔▔▔▔▔▔▔▔ │ │ ② ③ ④ ⑤ │ ├──────────────────────────────────────────────┤ │ 선택된 탭의 내용 │ ← ⑥ Panel └──────────────────────────────────────────────┘ ``` | # | 요소 | 필수 | 클래스 | 설명 | |---|---|---|---|---| | — | Container | 필수 | `atom-tabs` | 상단 모서리 12px, `bg-layer` 배경. | | ① | List | 필수 | `atom-tabs__list` | `role="tablist"`. 아래쪽에 1px 구분선을 갖습니다. | | ② | Trigger | 필수 | `atom-tabs__trigger` | `role="tab"`. 선택된 것만 진한 색과 600 굵기입니다. | | ③ | Label | 필수 | `atom-tabs__label` | 텍스트를 감싸는 `__label`. 넘칠 때 말줄임을 담당합니다. | | ④ | Count | 선택 | `atom-tabs__count` | 라벨 뒤에 붙는 개수. 선택입니다. | | ⑤ | Indicator | — | `atom-tabs__trigger::after` | 선택된 Trigger 아래 2px 막대. `::after` 로 그립니다. | | ⑥ | Panel | 필수 | `atom-tabs__panel` | `role="tabpanel"`. 선택되지 않은 것은 `hidden` 을 답니다. | --- ## 2. 종류 / 크기 ### Direction 레일을 어느 축에 둘지 정합니다. `horizontal` 은 레일이 위, 패널이 아래입니다. `vertical` 은 레일이 시작 방향 가장자리에 서고 패널이 그 옆에 붙습니다. | Direction | 레일 | 쓰는 곳 | | --- | --- | --- | | `horizontal` | 위쪽 가로 · 하단 경계선 | 라벨이 한 줄에 다 들어갈 때. 대부분의 화면 | | `vertical` | 시작 방향 세로 · 안쪽 경계선 | 탭이 많거나 라벨이 길 때, 설정 화면처럼 좌우 폭에 여유가 있을 때 | 세로에서는 항목이 늘어도 가로 폭은 그대로이고 아래로만 늘어납니다. 가로 레일에서 잘리던 긴 라벨도 그대로 표시됩니다. 좁은 화면에서는 레일이 콘텐츠 폭을 차지하므로, 모바일 폭에서는 `horizontal` 로 바꿉니다. > [!NOTE] > **세로에서 Layout 은 스크롤 여부만 가립니다** > 세로 레일은 나눠 가질 가로 폭이 없어 트리거가 항상 레일 전체 폭을 채우고 높이는 내용을 > 따릅니다. 그래서 `--layout_fit` 과 `--layout_scroll` 은 **레일이 넘칠 때 스크롤하는지**만 > 다릅니다. > [!WARNING] > **`aria-orientation="vertical"` 을 함께 답니다** > 방향을 클래스로만 바꾸면 스크린리더는 가로 탭으로 읽습니다. 세로에서는 `tablist` 에 > `aria-orientation="vertical"` 을 주고 `↑` `↓` 로 > 이동시켜야 합니다. ### Layout `fit` 은 남는 폭을 균등하게 나눠 갖고, `scroll` 은 각 탭이 라벨만큼만 차지한 뒤 넘치는 것을 가로 스크롤로 넘깁니다. | Layout | 폭 | 쓰는 곳 | | --- | --- | --- | | `fit` | 각 탭이 1/n | 모든 라벨이 말줄임 없이 들어가는 고정된 상태 필터 | | `scroll` | 라벨 폭 | 개수가 늘어나는 카테고리·태그 | > [!WARNING] > **늘어날 목록에 fit 을 쓰지 않습니다** > `fit` 은 탭이 늘어날수록 칸이 좁아지고, 라벨은 말줄임으로 잘립니다. 항목이 서버에서 > 오거나 앞으로 늘어날 수 있으면 처음부터 `scroll` 로 만드세요. ### Size `medium` 이 기본입니다. 화면 최상단의 주 탐색은 `medium`, 카드나 패널 안에 들어가는 보조 탭은 `small` 을 씁니다. | Size | 높이 | 폰트 | | --- | --- | --- | | `small` | 36px | 13px | | `medium` | 44px | 14px | ### Indicator Indicator 는 Trigger 폭보다 좌우 8px 짧은 2px 막대이고, 각 Trigger 의 `::after` 로 그립니다. 탭 사이를 **미끄러지지 않습니다.** 미끄러지게 하려면 막대가 List 의 자식으로 하나만 있어야 하고, 목표 탭의 위치를 재는 JS 가 필요합니다. 대신 가운데에서 가로로 늘어나며 나타납니다(`scaleX(0.4)` → `1`, `--atom-duration-base` + `--atom-ease-out`). Motion 의 규칙을 따르고, `base.css` 의 공통 규칙이 `prefers-reduced-motion` 에서 이 전환을 끕니다. ### Count 탭마다 항목 수를 붙일 수 있습니다. 숫자는 `tabular-nums` 라서 값이 바뀌어도 라벨이 흔들리지 않습니다. 0 이면 숫자를 표시하지 않습니다. `0` 이 붙은 탭은 빈 목록을 연다는 뜻이 되어 불필요한 클릭을 부릅니다. ### Panel Panel 높이는 내용을 따라갑니다. 선택되지 않은 Panel 은 `hidden` 을 달아 문서에서 빠지므로, 화면 낭독기가 보이지 않는 탭의 내용을 읽지 않습니다. ### 클래스 계약 `styles/elements/tabs.css` 헤더와 원본 `registry.json` `css.blocks` 기준입니다. 클래스가 없는 값(—)은 기본 스타일 그대로입니다. | Prop | 값 | 클래스 | |---|---|---| | `direction` | `horizontal` · `vertical` | — · `--direction_vertical` | | `layout` | `fit` · `scroll` | — · `--layout_scroll` | | `size` | `small` · `medium` | `--size_small` · — | 상태 선택자: `[aria-selected="true"]` · `:hover` · `[data-hover]` · `:focus-visible` · `[aria-disabled="true"]` · `[disabled]` · `__panel[hidden]` --- ## 3. 상태 선택 표시는 색·굵기·Indicator 세 가지를 함께 씁니다. 하나만 쓰면 흑백 화면이나 색각 이상 사용자에게 구분되지 않습니다. | State | 표현 | 선택자 | | --- | --- | --- | | 기본 | `--atom-fg-neutral-muted`, 기본 굵기 | `[aria-selected="false"]` | | 선택됨 | `--atom-fg-neutral`, 600 굵기, Indicator 노출 | `[aria-selected="true"]` | | hover | `--atom-fg-neutral` 로만 올라옴 | `:hover` | | focus | 안쪽으로 당긴 2px 브랜드 outline | `:focus-visible` | | 비활성 | 40% 투명도 | `[aria-disabled="true"]` | > [!NOTE] > **포커스 링을 안쪽으로 당긴 이유** > 공용 `:focus-visible` 은 `outline-offset: 2px` 이라 링이 Trigger 밖에 그려지고, > 그 자리에 List 하단 구분선이 있어 선이 끊긴 것처럼 보입니다. Tabs 는 > `outline-offset: -3px` 로 안쪽에 그립니다. --- ## 4. Props | Prop | Type | Default | 필수 | 설명 | |---|---|---|---|---| | `value` | `string` | | | 선택된 탭의 값입니다. | | `items` | `Array<{ value, label, count?, disabled? }>` | | ● | 탭 목록입니다. | | `direction` | `"horizontal" \| "vertical"` | `"horizontal"` | | 레일을 위에 둘지 시작 방향 옆에 둘지 정합니다. | | `layout` | `"fit" \| "scroll"` | `"fit"` | | 균등 분할과 라벨 폭 + 가로 스크롤 중 하나입니다. | | `size` | `"small" \| "medium"` | `"medium"` | | Trigger 높이와 폰트 크기를 결정합니다. | | `activationMode` | `"automatic" \| "manual"` | `"automatic"` | | 화살표 이동만으로 전환할지, Enter 로 확정할지입니다. | | `onValueChange` | `(value: string) => void` | | | 탭이 바뀔 때 호출됩니다. | 구현에서는 `value` 를 주면 controlled, 생략하면 `defaultValue`(없으면 첫 번째 비활성 아닌 탭)에서 시작하는 uncontrolled 입니다. `aria-label` · `aria-labelledby` 는 Container 가 아니라 `role="tablist"` 에 붙습니다. ### Parts (compound) | Part | 요소 | 설명 | |---|---|---| | `Tabs` | `