# 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` | `
` | Container. 위 Props 를 받고, `items` 로 List · Trigger · Label · Count 를 렌더합니다. | | `Tabs.Panel` | `
` | `value` 로 Trigger 와 연결합니다(`aria-controls` ↔ `aria-labelledby`). 선택되지 않으면 `hidden`. | --- ## 5. Component tokens | 대상 | 속성 | Token | Value | | --- | --- | --- | --- | | Container | radius | `{radius.r3}` | 12px (상단만) | | Container | background | `{color.bg.layer}` | | | List | 하단 구분선 | `{color.border.subtle}` | 1px inset shadow | | Trigger | color | `{color.fg.neutral-muted}` | | | Trigger (선택) | color | `{color.fg.neutral}` | 600 굵기 | | Trigger | gap | `{space.x1}` | 4px | | Trigger (fit) | padding-inline | `{space.x2}` | 8px | | Trigger (scroll) | padding-inline | `{space.x4}` | 16px | | Indicator | background | `{color.fg.neutral}` | 2px · 좌우 8px 들여쓰기 | | Indicator | transition | `{duration.base}` | `{easing.out}` | | Focus | outline | `{color.fg.brand}` | 2px · offset -3px | | Panel | padding | `{space.x4}` | 16px | 높이(44 · 36px), 폰트(14 · 13px), Indicator 두께 2px 는 토큰이 아닙니다. > [!NOTE] > **Indicator 색이 브랜드가 아닙니다** > 선택 표시가 `--atom-fg-neutral` 입니다. 다른 디자인 시스템은 대체로 브랜드 색을 쓰지만, > ATOM 은 Color 원칙("브랜드 색은 아껴 쓴다")을 따릅니다. > 이 컴포넌트에서 브랜드 색은 포커스 링에만 씁니다. 한 컴포넌트 안에서 브랜드 색이 두 가지 > 의미를 갖지 않게 하기 위해서입니다. ```json { "tabs": { "radius": { "$type": "dimension", "$value": "{radius.r3}" }, "background": { "$type": "color", "$value": "{color.bg.layer}" }, "divider": { "$type": "color", "$value": "{color.border.subtle}" }, "trigger": { "fg": { "$type": "color", "$value": "{color.fg.neutral-muted}" }, "fg-selected": { "$type": "color", "$value": "{color.fg.neutral}" }, "gap": { "$type": "dimension", "$value": "{space.x1}" }, "padding-inline": { "$type": "dimension", "fit": { "$value": "{space.x2}" }, "scroll": { "$value": "{space.x4}" } } }, "indicator": { "bg": { "$type": "color", "$value": "{color.fg.neutral}" }, "duration": { "$type": "duration", "$value": "{duration.base}" }, "easing": { "$type": "cubicBezier", "$value": "{easing.out}" } }, "focus": { "$type": "color", "$value": "{color.fg.brand}" }, "panel-padding": { "$type": "dimension", "$value": "{space.x4}" } } } ``` --- ## 6. 접근성 - **역할** — `role="tablist"` 안에 `role="tab"`, 내용에 `role="tabpanel"` 을 씁니다. - **연결** — Trigger 는 `aria-selected` 와 `aria-controls`(패널의 `id`)를, 패널은 `aria-labelledby`(탭의 `id`)를 갖습니다. - **이름** — `role="tablist"` 에 `aria-label` 로 무엇을 고르는 탭인지 붙입니다. 한 화면에 세트가 하나뿐이라도 필요합니다. - **키보드** — `←` `→` 로 탭 사이를 이동하고 끝에서 순환합니다. `Home` · `End` 는 처음·마지막 탭으로 갑니다. - **방향** — `direction="vertical"` 이면 `tablist` 에 `aria-orientation="vertical"` 을 주고 이동 키를 `↑` `↓` 로 바꿉니다. 클래스만 바꾸면 스크린리더는 가로로 읽습니다. - **roving tabindex** — 선택된 탭만 `tabindex="0"`, 나머지는 `-1` 입니다. 그래야 `Tab` 한 번으로 탭 목록을 지나 패널에 닿습니다. - **선택 시점** — 화살표 이동만으로 즉시 전환하는 방식(자동)과 `Enter` 로 확정하는 방식(수동)이 있습니다. 탭 전환이 네트워크 요청을 부르면 **수동**을 씁니다. - **비활성** — `disabled` 대신 `aria-disabled="true"` 를 씁니다. 포커스는 받되 선택되지 않으므로, 왜 못 고르는지 화면에서 설명할 수 있습니다. - **색만으로 구분하지 않습니다** — 선택 표시는 색·굵기·Indicator 세 가지가 함께 갑니다. | 키 | 동작 | | --- | --- | | `Tab` | 탭 목록에 들어오면 **선택된 탭**에 포커스가 갑니다 | | `←` `→` | 탭 사이를 이동합니다. 끝에서 순환합니다 | | `Home` · `End` | 첫 · 마지막 탭으로 이동합니다 | | `Tab` (탭에서) | 탭 목록을 빠져나와 패널로 이동합니다 | - **대비** — 선택 안 된 탭의 `--atom-fg-neutral-muted` 는 흰 배경 대비 6.99:1 로 충분합니다. Indicator 는 `--atom-fg-neutral`(17.19:1)입니다. - **폰트 크기** — `medium` 을 14px 로 올렸습니다. 탭 라벨은 주요 탐색 수단이므로 Typography 의 최소 12px 보다 크게 둡니다. 카드나 패널 안의 보조 탭(`small`)만 13px 로 남깁니다. --- ## 7. 사용 가이드 ### Tabs 는 탐색, Segmented Control 은 조작 **Tabs 는 서로 다른 내용으로 이동**하고, **Segmented Control 은 같은 내용을 다르게 봅니다.** | | Tabs | Segmented Control | | --- | --- | --- | | 목적 | 별개의 내용 사이를 이동 | 같은 내용을 필터링·정렬 | | 위치 | 화면 상단 | 제어하는 콘텐츠 바로 위 | | 영향 범위 | 탭 아래 전체 영역 | 화면의 특정 부분만 | | 예시 | 홈 / 검색 / 내 정보 | 목록 / 지도 · 최신순 / 인기순 | ### 한 화면에 한 세트만 탭이 두 겹이면 현재 어느 조합을 보고 있는지 알기 어렵습니다. 중첩이 필요하면 정보 구조를 다시 검토합니다. - ✅ **Do** 상위 이동만 탭으로 두고, 하위 구분은 [Chip](./chip.md) 이나 [Select](./select.md) 로 나눕니다. - ❌ **Don't** 탭 안에 탭을 두지 않습니다. ### 라벨은 한 줄로 표시합니다 탭이 하나면 탭을 쓰지 않습니다. 탭 라벨은 줄바꿈하지 않고 항상 한 줄로 표시합니다. `fit` 은 모든 라벨이 말줄임 없이 들어갈 때만 쓰고, 들어가지 않으면 `layout_scroll` 로 바꿉니다. 개수 기준은 두지 않습니다. 들어가는 개수는 탭 영역의 폭과 라벨 길이에 따라 달라집니다. - ✅ **Do** 모든 라벨이 한 줄에 다 보이면 `fit` 을 씁니다. - ❌ **Don't** 라벨이 말줄임으로 잘리는 상태로 `fit` 을 두지 않습니다. `layout_scroll` 로 바꿉니다. ### 라벨은 명사로 짧게 `fit` 에서는 가장 좁은 칸에 라벨이 들어가야 합니다. 2~4자 명사를 권장합니다. - ✅ **Do** "전체" · "진행중" · "완료" 처럼 명사로 짧게 씁니다. - ❌ **Don't** "진행 중인 항목 목록" 처럼 문장을 쓰지 않습니다. ### 탭을 옮겨도 내용이 사라지지 않게 탭을 옮기고 돌아왔을 때 스크롤 위치나 입력값이 초기화되면 작업 내용이 사라집니다. 각 탭의 상태를 유지하거나, 잃을 것이 있으면 미리 알립니다. 기본값은 항상 첫 번째 탭입니다. "마지막에 본 탭" 을 기억하면 같은 링크를 열 때마다 다른 화면이 나옵니다. 대신 탭 상태를 URL 에 남기면 공유와 뒤로 가기가 함께 동작합니다. ### Accordion 과 구분하기 | 상황 | 쓸 컴포넌트 | | --- | --- | | 한 번에 하나만 보여주고, 항목이 2개 이상 | **Tabs** | | 여러 개를 동시에 펼쳐 볼 수 있어야 한다 | [Accordion](./accordion.md) | | 항목이 많고 각각 내용이 짧다 | [Accordion](./accordion.md) | | 내용을 나란히 비교해야 한다 | 둘 다 아님 — 한 화면에 펼쳐 놓습니다 | ### 관련 컴포넌트 - [Chip](./chip.md) — 같은 목록을 필터링할 때. 탭 아래에서 함께 씁니다. - [Select](./select.md) — 선택지가 많아 가로로 늘어놓을 수 없을 때. --- ## 8. 예시 ```tsx … … ``` ```html
…
``` > [!NOTE] > **data-label 값은 라벨 텍스트와 같아야 합니다** > 선택되면 라벨이 600 굵기로 바뀌고, 그만큼 폭이 늘어납니다. `layout_scroll` 에서는 그 > 차이가 뒤따르는 탭 전체를 밀어냅니다. `__label` 이 `data-label` 로 굵은 상태의 폭을 > 미리 잡아 두므로, 값이 라벨 텍스트와 같아야 합니다. `data-hover` 는 문서용 훅입니다. 포인터 없이 hover 표현을 그려야 하는 데모와 스크린샷에서만 씁니다.