내비게이션
페이지와 섹션 사이를 오가는 컴포넌트.
Senco는 내비게이션을 두 단계까지만 둔다(Design principles 참고). 그래서 이 묶음은 일부러 작게 유지한다. 아래 컴포넌트가 다루는 이동은 세 가지뿐이다. 최상위 영역 사이, 한 방향으로 진행하는 플로우 안, 그리고 한 화면 안. 이 밖의 이동은 필요 없다. 위계가 세 번째 단계까지 필요해 보이면 내비게이션이 아니라 정보를 묶는 방식의 문제로 본다.
컴포넌트
| Component | Purpose | Landmark |
|---|---|---|
| Tabs | 한 화면 안에서 콘텐츠 섹션을 바꾼다 | — |
| Bottom tab bar (iOS app mobile) | 화면 하단의 주요 모바일 내비게이션 | <nav> |
| Stepper | 여러 단계 플로우의 진행을 보여 준다 | — |
| Breadcrumbs | 페이지 위계에서 사용자가 어디에 있는지 보여 준다 | <nav> |
| Menubar | 드롭다운 메뉴를 가로로 늘어놓은 바 | — |
| Side navigation | 사이드바에 두는 섹션 단위 내비게이션 | <nav> |
| Tree view | 중첩 구조를 펼치고 접는 목록 | — |
| Pagination | 결과 목록의 페이지를 넘긴다 | <nav> |
| Sheet | 화면 가장자리 어디서든 밀려 나오는 패널 | — |
무엇을 고를까
| Need | Use |
|---|---|
| 같은 페이지, 같은 데이터에서 보기만 바꾼다 | Tabs |
| 앱의 최상위 영역 사이를 오간다 | Bottom tab bar (모바일) / Side navigation (데스크톱) |
| 순서가 정해져 있고 되돌릴 수 없는 플로우를 진행한다 | Stepper |
| 사용자가 따라 내려온 위계에서 현재 위치를 보여 준다 | Breadcrumbs |
| 긴 결과 목록을 페이지 단위로 넘긴다 | Pagination |
| 페이지를 떠나지 않고 보조 콘텐츠를 보여 준다 | Sheet |
Tabs와 Stepper는 생김새는 비슷하지만 뜻은 정반대다. Tabs는 순서가 없어서 어느 탭이든 언제든 열 수 있다. Stepper는 순서가 있어서 앞 단계를 끝내야 다음 단계로 갈 수 있다. 송금 플로우를 탭으로 만들면, 금액을 넣기 전에 확인 단계로 가도 된다고 말하는 셈이 된다.
Tabs
페이지를 이동하지 않고 콘텐츠 섹션을 바꾼다. 전체 폭 레이아웃이고, 활성 탭에 밑줄을 긋는다.
- 높이 48px로 최소 터치 타깃을 충족한다
- 화살표 키로 이동할 수 있다
- 여기 보이는 밑줄 스타일은
variant="line",size="lg"로 만든다
구조
┌──────────┬──────────┬──────────┐
│ Recent │ Sent │ Received │
├══════════┴──────────┴──────────┤ active: 2px --ds-primary underline
│ │
│ panel content │
└────────────────────────────────┘
변형
| Variant | Dimension | Use |
|---|---|---|
| Line | style | 기본. 활성 탭에 밑줄 |
| Scrollable | layout | 탭이 다섯 개 이상일 때. 한 줄로 스크롤하고, 두 줄로 넘기지 않는다 |
API
interface TabsProps {
label: string; // accessible name for the tab list
tabs: Array<{ id: string; label: string; disabled?: boolean }>;
value: string;
onValueChange: (id: string) => void;
syncToUrl?: boolean; // default: true — a tab state should be shareable
children: React.ReactNode; // the active panel
}
Tabs는 <nav>가 아니라 role="tablist"로 렌더링한다. 보기를 바꿀 뿐, 페이지를 이동하지 않기 때문이다.
상태
| State | Visual |
|---|---|
| Active | --ds-primary 라벨, 굵기 600, 2px 밑줄 |
| Inactive | --ds-muted 라벨, 굵기 500, 밑줄 없음 |
| Hover | --ds-heading 라벨, --ds-surface 배경 |
| Focus-visible | 탭에 포커스 링, 밑줄은 그대로 |
| Disabled | --ds-disabled-text, 화살표 키 이동에서 건너뜀 |
동작
- 탭 콘텐츠는 처음 열 때 불러오고, 그 뒤로는 마운트된 채 둔다. 다시 돌아와도 데이터를 새로 가져오지 않는다.
- 탭이 다섯 개 이상이면 줄을 가로로 스크롤하고, 활성 탭이 화면에 보이게 맞춘다. 두 줄로 넘기지 않는다.
- 활성 탭을 URL에 반영해서, 탭 상태를 공유하거나 복원할 수 있게 한다.
콘텐츠
탭 라벨은 한두 단어로, 형태를 맞추고, 액션이 아니라 콘텐츠를 가리키는 이름을 쓴다.
접근성
- 탭 목록 전체가 Tab 정지점 하나다. 탭 사이는 화살표 키로 이동한다.
role="tablist"/role="tab"/role="tabpanel"을 쓰고,aria-controls와aria-selected를 단다.- 패널에
tabindex="0"을 줘서, 키보드 사용자가 탭을 고른 뒤 패널을 스크롤할 수 있게 한다. - Tabs는
<nav>로 감싸지 않는다. 보기를 바꿀 뿐, 페이지를 이동하지 않는다.
| Key | Action |
|---|---|
← → | 이전 / 다음 탭 (끝에 닿으면 처음으로) |
Home / End | 첫 / 마지막 탭 |
Tab | 탭 목록에서 패널로 이동 |
토큰
--ds-primary · --ds-muted · --ds-surface · --ds-border · --ds-focus-ring
Bottom tab bar (iOS app mobile)
화면 하단에 고정한다. 스크롤하는 동안에도 항상 보인다.
- 탭은 최대 4–5개. 그보다 많으면 너무 빽빽하다
- 아이콘에는 항상 라벨을 붙인다. 아이콘만으로는 뜻이 충분히 전달되지 않는다.
- 알림 배지(숫자 또는 점)를 지원한다
구조
| Part | Token | Value |
|---|---|---|
| 바 높이 | --ds-size-bottomnav | 52px |
| 배경 | --ds-bottomtab-bg | --ds-white |
| 위 테두리 | --ds-bottomtab-border | --ds-gray-200 |
| 활성 아이콘 + 라벨 | --ds-bottomtab-active | --ds-accent-600 |
| 비활성 아이콘 + 라벨 | --ds-bottomtab-inactive | --ds-gray-500 |
| 배지 배경 / 텍스트 | --ds-bottomtab-badge-bg / -text | --ds-red-600 / 흰색 |
| 아이콘 크기 | --ds-size-icon | 20px |
| 라벨 크기 | --ds-text-2xs | 10px |
변형
| Variant | Dimension | Use |
|---|---|---|
| Four items | layout | 기본 |
| Five items | layout | 최대치. 여섯 번째 목적지가 필요하다면 정보 구조가 잘못된 것이다 |
API
interface BottomTabBarProps {
items: Array<{
id: string;
label: string; // one word — 10px leaves no room for two
icon: React.ReactNode;
href: string;
badge?: number | "dot";
}>; // 4–5 items
activeId: string;
onActiveReselect?: (id: string) => void; // tapping the active tab scrolls its view to top
}
배지는 숫자만 따로 읽히게 두지 않고, 항목의 접근 가능한 이름에 합친다("Activity, 3 new").
상태
| State | Rendering |
|---|---|
| Inactive | --ds-bottomtab-inactive 아이콘과 라벨 |
| Active | --ds-bottomtab-active, aria-current="page" |
| With badge | --ds-bottomtab-badge-bg 위에 숫자나 점. 접근 가능한 이름에 합친다 |
| Active reselected | 그 화면을 맨 위로 스크롤한다. 페이지 이동은 없다 |
동작
- 바는
env(safe-area-inset-bottom)위에 둔다. inset을 빼먹으면 가장 오른쪽 탭이 홈 인디케이터 아래로 들어가, 탭 입력의 상당수를 OS가 가로챈다. - 바는 sticky가 아니라 fixed다. 스크롤해도 숨기지 않는다. 긴 거래 목록을 내려 본 사용자가 다른 곳으로 가려고 다시 위로 올라가게 만들면 안 된다.
- 활성 탭을 다시 누르면 그 화면이 맨 위로 스크롤된다.
- 바는 라벨까지 52px이지만 각 탭의 터치 영역은 48px 높이다. 타깃은 바 높이 전체에 걸친다.
콘텐츠
라벨은 한 단어로, 액션이 아니라 장소를 가리킨다. "Home", "Send", "Activity", "Profile" 같은 식이다. 10px 라벨에는 두 단어가 들어갈 자리가 없고, 그렇다고 아이콘만으로는 부족하다. 아이콘만 있는 하단 내비게이션은 사용성 테스트에서 번번이 잘못 읽히고, 특히 "Activity"와 "Home"을 헷갈린다.
접근성
<nav aria-label="Main">으로 감싼다.- 활성 탭에
aria-current="page"를 단다. - 배지는 별도 요소로 읽히지 않고, 탭의 접근 가능한 이름에 포함해 읽힌다("Activity, 3 new").
- 화면에 보이는 라벨이 그대로 접근 가능한 이름이 된다. 라벨과 다른 내용의
aria-label을 붙이지 않는다.
키보드
터치용 UI다. 키보드를 연결하면 각 항목은 탭 순서에 들어가는 링크가 된다. 바는 복합 컨트롤이 아니라 목적지 모음이라서 화살표 키 이동은 없다.
| Key | Action |
|---|---|
Tab | 항목마다 정지점이 하나씩 |
Enter | 이동 |
토큰
--ds-bottomtab-bg · --ds-bottomtab-border · --ds-bottomtab-active · --ds-bottomtab-inactive · --ds-bottomtab-badge-bg · --ds-bottomtab-badge-text · --ds-size-bottomnav · --ds-size-icon · --ds-text-2xs
Stepper
여러 단계로 된 플로우에서 어디까지 왔는지 보여 준다. 변형은 세 가지다. 긴 플로우(4단계 이상)에는 circle, 짧은 플로우(2–3단계)에는 line, 시각을 함께 보여 주는 타임라인에는 vertical line을 쓴다.
구조
단계마다 점, 연결선, 라벨, 시각(선택)이 들어간다. 완료된 점은 체크가 든 24px, 현재 단계와 남은 단계의 점은 12px이다.
변형
| Variant | Steps | Use |
|---|---|---|
| Circle | 4+ | 인증, 온보딩 |
| Line | 2–3 | 송금 플로우 |
| Vertical line | 제한 없음 | 시각을 함께 보여 주는 상태 타임라인 |
API
interface StepperProps {
variant?: "circle" | "line" | "vertical"; // default: "line" for 2–3 steps, "circle" for 4+
steps: Array<{ id: string; label: string; timestamp?: Date }>;
currentIndex: number;
completedIndices: number[];
errorIndex?: number;
onStepSelect?: (index: number) => void; // completed steps only
}
상태
| Step state | Visual | Announced as |
|---|---|---|
| Completed | 채운 원, 체크 아이콘 | "Step 1, Amount, completed" |
| Current | --ds-primary로 채운 원, 번호 | "Step 2, Recipient, current step" |
| Upcoming | --ds-gray-300 외곽선, 번호 | "Step 3, Confirm" |
| Error | --ds-red-600, 경고 아이콘 | "Step 2, Recipient, needs attention" |
동작
- 완료된 단계는 누를 수 있고, 입력한 데이터가 그대로 남은 채 그 단계로 돌아간다. 남은 단계는 누를 수 없다.
- 뒤로 가도 진행 상황은 사라지지 않는다. 3단계에서 1단계로 돌아가도 2단계 입력은 그대로 남아 있다.
- 모바일에서 스테퍼는 플로우 맨 위, 스크롤 영역 바깥에 항상 보인다.
콘텐츠
단계 라벨은 화면이 아니라 할 일을 가리키는 한두 단어로 쓴다. "Amount", "Recipient", "Confirm" 같은 식이다.
접근성
- 순서 있는 목록으로 렌더링해서, 전체 개수와 현재 위치가 마크업 구조로 전달되게 한다.
- 현재 단계에
aria-current="step"을 단다. - 상태는 색만으로 전하지 않고 아이콘과 텍스트를 함께 쓴다. 완료된 단계는 색만 채우지 않고 체크도 넣는다.
- 남은 단계는 탭 순서에서 빼지 않고
aria-disabled="true"로 표시한다.
키보드
| Key | Action |
|---|---|
Tab | 스테퍼 전체가 정지점 하나 |
← → | 이동 가능한 단계 사이 이동 |
Enter | 포커스된 단계로 이동 (완료된 단계만) |
토큰
--ds-timeline-completed-dot · --ds-timeline-active-dot · --ds-timeline-future-dot · --ds-timeline-line-completed · --ds-red-600
Breadcrumbs
페이지 위계에서 사용자가 어디에 있는지 보여 준다. 마지막 항목은 현재 페이지라서 링크가 아니라 일반 텍스트로 둔다.
- 경로가 깊어지면 모바일에서는 말줄임표로 접는다
- 항목 사이에 구분자(화살표 또는 슬래시)를 둔다
구조
aria-hidden 화살표로 구분한 링크가 한 줄로 이어지고, 맨 끝은 현재 페이지 이름을 일반 텍스트로 둔다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Full | layout | 세 단계 이하 |
| Collapsed | layout | 가운데 단계를 펼침 컨트롤 안에 접어 둔다 |
API
interface BreadcrumbsProps {
items: Array<{ label: string; href?: string }>; // the last item omits href — it is the current page
collapseAfter?: number; // default: 3 — collapses the middle into an expandable button
}
구분자는 장식이므로 aria-hidden이다. 스크린 리더가 "Home slash Settings"라고 읽는다면 문장 부호를 내용으로 읽고 있는 것이다.
상태
| State | Rendering |
|---|---|
| Full | 모든 단계를 보여 준다 |
| Collapsed | --ds-bp-mobile 미만이거나 collapseAfter를 넘으면 가운데 단계를 펼침 버튼 안에 접어 둔다 |
| Current | 마지막 항목, 일반 텍스트, aria-current="page" |
동작
내비게이션을 두 단계까지만 두므로, 브레드크럼은 제품보다 주로 문서 사이트에서 쓴다. 앱에서는 App bar의 뒤로 가기 버튼이 더 적은 UI로 같은 역할을 한다.
구분자는 장식이므로 aria-hidden이다. 스크린 리더가 "Home slash Settings slash Security"라고 읽는다면 문장 부호를 내용으로 읽고 있는 것이다.
콘텐츠
각 항목에는 목적지 페이지의 제목을 그대로 쓴다. 페이지 제목을 다르게 바꿔 쓰면 경로를 믿을 수 없게 된다.
접근성
<nav aria-label="Breadcrumb">으로 감싸고, 안에 순서 있는 목록을 둔다.- 현재 페이지에는
aria-current="page"를 달고 링크로 만들지 않는다. - 접힌 말줄임표는 그냥 문자가 아니라, 누르면 전체 경로를 펼치는 버튼이다.
키보드
| Key | Action |
|---|---|
Tab | 링크와 접기 컨트롤을 차례로 이동 |
Enter | 링크로 이동하거나, 접힌 구간을 펼친다 |
토큰
--ds-link · --ds-muted · --ds-heading · --ds-text-sm
Menubar
드롭다운 메뉴를 가로로 늘어놓은 줄로, 보통 화면 맨 위에 둔다. 각 항목을 누르면 관련 액션을 모은 메뉴가 열린다.
- 활성 메뉴는 Primary 색에 은은한 배경 색을 깐다
- 메뉴 안에 체크 항목과 라디오 그룹을 넣을 수 있다
구조
메뉴 트리거를 가로로 늘어놓는다. 트리거마다 항목 패널이 열리고, 패널에는 구분선, 체크박스 항목, 라디오 그룹이 들어갈 수 있다.
변형
하나뿐이다. 데스크톱용 UI이고, 터치 환경에서는 같은 액션을 Sheet로 연다.
API
interface MenubarProps {
label: string;
menus: Array<{
id: string;
label: string;
items: Array<
| { type: "action"; label: string; onSelect: () => void; disabled?: boolean }
| { type: "checkbox"; label: string; checked: boolean; onChange: (v: boolean) => void }
| { type: "radio"; label: string; value: string; groupId: string }
| { type: "separator" }
>;
}>;
}
데스크톱 패턴이다. 터치 환경에서는 같은 액션을 Sheet에 담아 보여 준다.
상태
| State | Rendering |
|---|---|
| Closed | 모든 메뉴가 접혀 있다 |
| Open | 메뉴가 하나만 열린다. 하나를 열면 나머지는 닫힌다 |
| Active item | --ds-primary에 옅은 색 배경 |
동작
메뉴 하나를 열면 다른 메뉴는 닫힌다. 메뉴가 열린 상태에서 포인터를 다른 트리거로 옮기면 다시 클릭하지 않아도 그 메뉴로 바뀐다.
콘텐츠
트리거 라벨은 한 단어로 쓴다. 항목 라벨은 동사로 시작한다.
접근성
role="menubar"안에role="menuitem"자식을 둔다. 바 전체가 Tab 정지점 하나다.←→로 메뉴 사이를 이동하고,↓로 메뉴를 열고,Esc로 메뉴를 닫으면 포커스가 트리거로 돌아간다.- 체크할 수 있는 항목은
role="menuitemcheckbox", 하나만 고르는 선택지는role="menuitemradio"를 쓴다.
Menubar는 데스크톱 패턴이다. 모바일 제품에서는 쓰지 않고, 같은 액션을 시트에 담아 보여 준다.
키보드
| Key | Action |
|---|---|
Tab | 메뉴 바 전체가 정지점 하나 |
← → | 메뉴 사이 이동 |
↓ | 포커스된 메뉴 열기 |
↑ ↓ | 열린 메뉴 안에서 이동 |
Esc | 닫고 트리거로 포커스를 돌려준다 |
토큰
--ds-primary · --ds-primary-light · --ds-white · --ds-border · --ds-shadow-lg · --ds-z-dropdown
Side navigation
페이지나 앱 안의 섹션을 오가는 사이드바 내비게이션. 활성 항목 왼쪽에 Primary 색 테두리를 둔다.
- 들여쓰기로 중첩 섹션을 표현한다
- 데스크톱에서는 고정하고, 모바일에서는 접는다
구조
섹션을 세로로 쌓는다. 섹션마다 제목과 항목이 있다. 활성 항목에는 --ds-sidebar-active 색으로 왼쪽 테두리를 단다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Fixed | layout | 1024px 이상 |
| Collapsed | layout | 1024px 미만. Sheet로 연다 |
API
interface SideNavProps {
label: string; // distinct from the main navigation landmark
sections: Array<{
title: string;
items: Array<{ label: string; href: string }>;
}>;
activeHref: string;
collapsedBelow?: number; // default: 1024 — becomes a Sheet below this width
}
상태
| State | Visual | Token |
|---|---|---|
| Default | --ds-fg 라벨 | --ds-sidebar-bg |
| Hover | --ds-sidebar-hover 배경 | — |
| Active | --ds-sidebar-active 왼쪽 테두리와 라벨, 옅은 색 배경 | --ds-sidebar-active-bg |
| Focus-visible | 안쪽으로 3px 실선 외곽선 | --ds-focus |
동작
- 1024px 미만에서는 사이드바를 트리거 버튼 안으로 접고, 누르면 시트로 연다.
- 페이지를 이동해도 활성 섹션은 펼친 채 두고, 다른 섹션은 각자의 열림/닫힘 상태를 유지한다.
- 사이드바 안의 스크롤 위치는 페이지를 바꿔도 유지한다.
콘텐츠
섹션 제목은 명사로 쓴다. 항목 라벨은 목적지 페이지의 제목과 똑같이 쓴다.
접근성
<nav aria-label="Sections">로 감싸서 주요 내비게이션 랜드마크와 구분한다.- 활성 항목에
aria-current="page"를 단다. - 중첩 그룹에는 tree 역할을 중첩하지 않고,
aria-expanded를 쓰는 펼침 패턴을 쓴다.
키보드
| Key | Action |
|---|---|
Tab | 화면에 보이는 순서대로 항목을 이동 |
Enter | 링크로 이동 |
Esc | Sheet로 열려 있을 때 닫는다 |
토큰
--ds-sidebar-bg · --ds-sidebar-border · --ds-sidebar-active · --ds-sidebar-active-bg · --ds-sidebar-hover
Tree view
펼치고 접을 수 있는 중첩 목록. 파일 탐색기, 계정 구조, 범주 트리에 알맞다.
- 화살표 아이콘으로 펼칠 수 있는 노드를 표시한다
- 들여쓰기로 깊이를 보여 준다
구조
깊이에 따라 들여쓰기가 늘어나는 행으로 이뤄진다. 펼칠 수 있는 행은 inline-start에 화살표를 단다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Flat | layout | 한 단계, 화살표 없음 |
| Nested | layout | 펼치고 접을 수 있는 여러 단계 |
API
interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
}
interface TreeViewProps {
label: string;
nodes: TreeNode[];
expandedIds: string[];
onExpandedChange: (ids: string[]) => void;
selectedId?: string;
onSelect?: (id: string) => void;
}
깊이는 들여쓰기가 아니라 aria-level로 전한다. 스크린 리더는 들여쓰기를 볼 수 없다.
상태
| State | Rendering |
|---|---|
| Collapsed | inline-start 화살표, aria-expanded="false" |
| Expanded | aria-expanded="true" |
| Leaf | 화살표 없음, aria-expanded 없음 |
| Selected | aria-selected="true" |
동작
펼침 상태는 controlled로 관리하고, 다시 렌더링해도 유지한다. 부모를 선택해도 자식은 선택되지 않는다.
콘텐츠
노드 라벨은 명사로 쓴다. 부모와의 관계는 깊이로 드러나므로 라벨에 부모 이름을 반복하지 않는다.
접근성
role="tree"/role="treeitem"을 쓰고, 펼칠 수 있는 노드에는aria-expanded를, 모든 항목에는aria-level을 단다.- Tab 정지점은 하나다.
↑↓로 보이는 노드 사이를 이동하고,→로 펼치거나 한 단계 내려가고,←로 접거나 한 단계 올라간다. - 들여쓰기는 눈으로만 보인다. 스크린 리더는 들여쓰기를 알 수 없으니 깊이는
aria-level로 전해야 한다.
Tree view는 Senco 제품에서 쓰지 않는다. 문서 사이트와, 나중에 생길 수 있는 관리자 화면용이다. 내비게이션을 두 단계까지만 두는 원칙 때문에 앱에는 들이지 않는다.
키보드
| Key | Action |
|---|---|
Tab | 트리 전체가 정지점 하나 |
↑ ↓ | 보이는 노드 사이 이동 |
→ | 펼치거나, 첫 자식으로 이동 |
← | 접거나, 부모로 이동 |
Enter | 선택 |
토큰
--ds-fg · --ds-surface · --ds-primary-light · --ds-focus-ring · --ds-size-icon-sm
Pagination
긴 결과 목록을 페이지 단위로 넘긴다. 양 끝에 이전/다음 버튼을 두고, 페이지가 많으면 중간을 말줄임표로 줄인다.
구조
이전 버튼, 말줄임표로 줄인 페이지 번호 구간, 다음 버튼으로 이뤄진다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Numbered | layout | 포인터 위주 화면, 전체 페이지 수를 알 때 |
| Prev/Next only | layout | 전체 개수를 모를 때 |
API
interface PaginationProps {
page: number; // 1-based
pageCount: number;
onPageChange: (page: number) => void;
siblingCount?: number; // default: 1
}
페이지 컨트롤의 접근 가능한 이름은 "3"이 아니라 "Page 3"이다. 첫 페이지에서 이전 버튼은 숨기지 않고 비활성으로 둔다. 그래야 컨트롤 줄이 옆으로 밀리지 않는다.
상태
| State | Rendering |
|---|---|
| First page | 이전 버튼은 숨기지 않고 비활성. 컨트롤 줄이 밀리면 안 된다 |
| Last page | 다음 버튼은 비활성 |
| Current | 링크가 아니라 텍스트, aria-current="page" |
| Truncated | 말줄임표는 aria-hidden. 건너뛴 범위는 앞뒤 링크 이름으로 알 수 있다 |
동작
- 거래 내역은 모바일에서는 무한 스크롤, 데스크톱에서는 페이지네이션을 쓴다. 페이지네이션이면 키보드 사용자도 페이지 끝에 닿을 수 있지만, 무한 스크롤은 그렇지 않다.
- 첫 페이지에서 이전 버튼은 숨기지 않고 비활성으로 둬서 컨트롤 줄이 밀리지 않게 한다.
- 현재 페이지는 링크가 아니라 텍스트로 보여 준다.
콘텐츠
컨트롤 이름은 "3"이 아니라 "Page 3"으로 쓴다. 현재 위치는 "Page 3 of 10"처럼 적는다.
접근성
<nav aria-label="Pagination">.- 현재 페이지에
aria-current="page"를 단다. - 각 페이지 링크의 접근 가능한 이름은 "3"이 아니라 "Page 3"이다.
- 말줄임표는
aria-hidden이다. 건너뛴 범위는 앞뒤 링크의 접근 가능한 이름으로 알 수 있다.
키보드
| Key | Action |
|---|---|
Tab | 컨트롤을 차례로 이동 |
Enter | 그 페이지로 이동 |
토큰
--ds-primary · --ds-muted · --ds-border · --ds-disabled-text · --ds-focus-ring
Sheet
화면 가장자리 어디서든 밀려 나오는 패널. 바텀 시트는 Senco 모바일의 핵심 패턴으로, 내비게이션 서랍, 상세 패널, 필터에 쓴다.
- 배경을 탭하면 시트가 닫힌다
- 크기: sm, default, lg, full
구조
배경, 화면 안쪽을 향한 가장자리에 radius를 준 패널, 제목과 닫기 컨트롤이 있는 헤더, 스크롤되는 본문, 푸터(선택)로 이뤄진다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Bottom | layout | 모바일 기본 |
| Start / end | layout | 데스크톱 사이드 패널 |
| Full | layout | 화면을 꽉 채우는 작업 |
API
interface SheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "bottom" | "start" | "end"; // default: "bottom"
size?: "sm" | "default" | "lg" | "full"; // default: "default"
title: string; // required — a modal surface needs an accessible name
children: React.ReactNode;
}
시트는 겹쳐 띄우지 않는다. 두 번째 시트를 열면 첫 번째를 대신한다. 스와이프로만 닫게 하지 않는다. 배경 탭, Escape, 눈에 보이는 닫기 컨트롤이 모두 동작해야 한다(SC 2.5.7).
상태
| State | Rendering |
|---|---|
| Closed | 렌더링하지 않는다 |
| Entering | --ds-duration-slow, --ds-ease-out |
| Open | 포커스를 가두고, 뒤의 콘텐츠는 inert, body 스크롤은 잠긴다 |
| Dragging | 바텀 시트만. 포인터를 따라 움직이고, 닫힘 기준에 못 미치면 제자리로 돌아간다 |
| Exiting | --ds-duration-normal, --ds-ease-in |
동작
| Property | Value |
|---|---|
| 등장 | --ds-duration-slow (300ms), --ds-ease-out |
| 퇴장 | --ds-duration-normal (180ms), --ds-ease-in |
| Radius | 화면 안쪽을 향한 가장자리에만 --ds-radius-2xl |
| 배경 | --ds-overlay |
| Z-index | --ds-z-modal |
| Size | Bottom sheet height | Side sheet width |
|---|---|---|
sm | 40vh | 320px |
default | 60vh | 400px |
lg | 85vh | 560px |
full | 100dvh, 위쪽 radius 없음 | 100vw |
| Part | Specification |
|---|---|
| 헤더 | 제목 --ds-text-lg 600, --ds-space-5 패딩, inline-end에 닫기 컨트롤(X 아이콘, 48px 타깃) |
| 본문 | 페이지와 따로 스크롤된다. 가로 패딩 --ds-space-5 |
| 드래그 핸들 | 바텀 시트만. 36×4px, --ds-border, --ds-radius-full, 위 가장자리에서 --ds-space-2 아래 |
| 스와이프로 닫기 | 시트 높이의 30% 이상 끌어내리고 놓거나, 아래로 0.5px/ms보다 빠르게 밀 때 닫힌다. 그 외에는 --ds-duration-normal 동안 제자리로 돌아간다 |
| 배경 | --ds-overlay. 라이트 테마에서 gray-900 10%, 다크 테마에서 30% |
| 포털 | 현재 테마 범위(data-ds-theme)를 지정한 포털에 렌더링한다. 그래야 페이지 범위 밖에서도 semantic 토큰 값이 제대로 잡힌다 |
- 시트가 열려 있는 동안 body 스크롤을 잠근다.
- 바텀 시트는 아래로 스와이프해서 닫을 수 있지만, 스와이프로만 닫게 하지 않는다. 배경 탭,
Esc, 눈에 보이는 닫기 컨트롤이 모두 동작해야 한다(SC 2.5.7). - 시트는 겹쳐 띄우지 않는다. 두 번째 시트를 열면 첫 번째를 대신한다.
콘텐츠
제목에는 할 일을 적는다. 제목이 없으면 접근 가능한 이름 없는 모달이 된다.
접근성
role="dialog"에aria-modal="true"를 주고, 제목으로 이름을 붙인다.- 열리면 포커스가 시트 안으로 들어가고, 닫히면 트리거로 돌아간다.
- 열려 있는 동안 포커스를 가둔다. Utilities 참고.
Esc로 닫는다.
토큰
--ds-overlay · --ds-z-modal · --ds-radius-2xl · --ds-duration-slow · --ds-ease-out
디자인과 접근성
모든 내비게이션 컴포넌트는 키보드로 조작할 수 있어야 한다. 항목 사이는 화살표 키로 이동하고, Enter나 Space로 선택한다. 현재 페이지나 섹션은 aria-current로 표시해서 스크린 리더가 읽어 줄 수 있게 한다. Tabs, Bottom tab bar, Breadcrumbs에는 <nav> 랜드마크를 쓰고 무엇인지 알 수 있는 aria-label을 붙인다. DOM의 내비게이션 순서는 화면에 보이는 순서와 같아야 한다.
여기 모든 컴포넌트에 적용되는 규칙
- 복합 컴포넌트는 Tab 정지점이 하나다. 안에서는 화살표 키로 이동한다.
- 모든
<nav>에는 서로 다른aria-label을 붙인다. 라벨 없는 내비게이션 랜드마크가 한 페이지에 두 개 있으면 스크린 리더 사용자는 둘을 구분할 수 없다. aria-current에는 맞는 값을 쓴다. 내비게이션은page, 스테퍼는step이다.- 현재 상태를 색만으로 전하지 않는다. 굵기, 밑줄, 테두리, 아이콘 중 하나를 함께 쓴다.
- DOM 순서는 화면 순서와 같게 둔다. 내비게이션에
order나row-reverse를 쓰지 않는다.
권장과 금지
| Do | Don't |
|---|---|
| Use a stepper for the transfer flow | Use tabs for a flow with required order |
| Show icon and label in the bottom tab bar | Ship icon-only bottom navigation |
| Let a completed step be revisited | Let an upcoming step be skipped ahead to |
| Respect the bottom safe-area inset | Pin the tab bar to bottom: 0 |
Give each <nav> an aria-label | Ship three unlabelled navigation landmarks |
알려진 공백
- 같은 내비게이션 맥락에서, 데스크톱 전용인 Hover card 상호작용을 모바일에서 어떻게 대체할지 문서가 없다.
- Tree view와 Menubar는 스펙은 있지만 제품에서는 쓰지 않는다. 문서 사이트용 컴포넌트다.
- 시트 겹쳐 띄우기는 규칙으로 금지했지만 컴포넌트 차원에서 막지는 않는다.