Dock
화면 하단에 고정되는 독 스타일 내비게이션 바
목차
모바일/태블릿 앱 하단에 고정되는 아이콘 + 라벨 기반 내비게이션 바입니다. 5단계 사이즈(xs/sm/md/lg/xl)를 지원하며
xs/sm에서는 라벨이 자동으로 숨겨집니다.
기본 사용법 (Basic Usage)#
Dock(
items: [
CoreDockItemData<Widget>(
icon: Icon(LucideIcons.house),
label: 'Home',
isActive: true,
onPressed: () {},
),
CoreDockItemData<Widget>(
icon: Icon(LucideIcons.search),
label: 'Search',
onPressed: () {},
),
CoreDockItemData<Widget>(
icon: Icon(LucideIcons.user),
label: 'Profile',
onPressed: () {},
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Dock 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.surfaceContainer처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(surfaceContainer ==
CoreColors.surfaceContainer) 어느 컴포넌트에서 써도 뜻이 갈리지 않으며, 위 예시처럼 withStyle 뒤에 이어붙일 수도 있습니다.
class DockChainExample extends StatefulWidget {
const DockChainExample({super.key});
@override
State<DockChainExample> createState() => _DockChainExampleState();
}
class _DockChainExampleState extends State<DockChainExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
CoreDockItemData<Widget> item(
int idx,
IconName icon,
String label,
) {
return CoreDockItemData<Widget>(
icon: Icon(icon),
label: label,
isActive: _index == idx,
onPressed: () => setState(() => _index = idx),
);
}
return Dock(
items: [
item(0, LucideIcons.house, 'Home'),
item(1, LucideIcons.search, 'Search'),
item(2, LucideIcons.user, 'Profile'),
],
)
.withStyle(
const CoreDockStyle(
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space24),
barRadius: CoreBorderRadius.all(CoreRadius.radius24),
barMargin: CoreEdgeInsets.all(CoreSpace.space8),
),
)
.surfaceContainer;
}
}
class DockChainExample extends StatefulComponent {
const DockChainExample({super.key});
@override
State<DockChainExample> createState() => _DockChainExampleState();
}
class _DockChainExampleState extends State<DockChainExample> {
int _index = 0;
@override
Component build(BuildContext context) {
CoreDockItemData<Component> item(int idx, IconName icon, String label) {
return CoreDockItemData<Component>(
icon: Icon(icon),
label: label,
isActive: _index == idx,
onPressed: () => setState(() => _index = idx),
);
}
return Dock(
items: [
item(0, LucideIcons.house, 'Home'),
item(1, LucideIcons.search, 'Search'),
item(2, LucideIcons.user, 'Profile'),
],
)
.withStyle(
const CoreDockStyle(
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space24),
barRadius: CoreBorderRadius.all(CoreRadius.radius24),
barMargin: CoreEdgeInsets.all(CoreSpace.space8),
),
)
.surfaceContainer;
}
}
Live Preview#
코드 보기
class DockDefaultExample extends StatefulComponent {
const DockDefaultExample({super.key});
@override
State<DockDefaultExample> createState() => _DockDefaultExampleState();
}
class _DockDefaultExampleState extends State<DockDefaultExample> {
int _index = 0;
@override
Component build(BuildContext context) {
CoreDockItemData<Component> item(int idx, IconName icon, String label) {
return CoreDockItemData<Component>(
icon: Icon(icon),
label: label,
isActive: _index == idx,
onPressed: () => setState(() => _index = idx),
);
}
return Dock(
items: [
item(0, LucideIcons.house, 'Home'),
item(1, LucideIcons.search, 'Search'),
item(2, LucideIcons.user, 'Profile'),
],
);
}
}
class DockDefaultExample extends StatefulWidget {
const DockDefaultExample({super.key});
@override
State<DockDefaultExample> createState() => _DockDefaultExampleState();
}
class _DockDefaultExampleState extends State<DockDefaultExample> {
int _index = 0;
@override
Widget build(BuildContext context) {
CoreDockItemData<Widget> item(int idx, IconName icon, String label) {
return CoreDockItemData<Widget>(
icon: Icon(icon),
label: label,
isActive: _index == idx,
onPressed: () => setState(() => _index = idx),
);
}
return Dock(
items: [
item(0, LucideIcons.house, 'Home'),
item(1, LucideIcons.search, 'Search'),
item(2, LucideIcons.user, 'Profile'),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 앱의 최상위 3~5개 탭 사이를 이동할 때
- 현재 위치를 시각적으로 표시해야 할 때 (active item)
대신 다른 컴포넌트를 사용하세요:
- NavigationBar: bar / rail / sidebar 모드로 섹션 목록을 배치해야 할 때. Dock은 하단 아이콘 중심의 항목 띠입니다
- Tabs: 앱 섹션 이동 대신 본문 패널 하나를 전환할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. CoreDockItemData<W>
의 icon도 같은 플랫폼 콘텐츠입니다.
Dock#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreDockItemData<W>> |
필수 | 렌더링할 아이템 목록 |
size |
CoreDockSize? |
md |
xs/sm/md/lg/xl 높이 스텝 |
semanticLabel |
String? |
null → 로케일 |
navigation 랜드마크 이름 |
dockStyle |
CoreDockStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 표 참고) |
CoreDockStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
height |
double? |
Minimum height of the dock bar, border included (logical px, pre-scaling). When null, falls back to [defaultsBySize] for the active [CoreDockSize]. A floor, not a fixed height. While the item row — and the pill around its active item, which is why [indicatorClearanceFor] is part of the measure — fits, the bar is exactly this tall; when the row needs more room (larger text, a taller caller icon, a larger label style) the bar grows with it, and the items stay centred in it either way. Why the bar grows instead of the content giving way: an OS text-size setting enlarges the label and leaves the icon and this value where they were, so a fixed height ends up clipping content the user asked to see larger (WCAG 1.4.4 asks for 200% text without loss of content or function). Clamping the label's scale or hiding it would give that content up to keep a number; growing the bar costs a few pixels. |
padding |
CoreEdgeInsets? |
Padding applied to the dock bar. When null, falls back to [defaultPadding]. |
barRadius |
CoreBorderRadius? |
The bar's own corners. Distinct from the item radius: this rounds the bar, which is what makes a detached floating bar read as one object rather than a strip with rounded buttons in it. When null, falls back to [defaultBarRadius] — square, which is what a bar docked to an edge wants. |
barMargin |
CoreEdgeInsets? |
Space outside the bar. What detaches the bar from the edge it would otherwise sit flush against. Padding cannot express this — padding moves the items and leaves the bar's own box where it was. When null, falls back to [defaultBarMargin] — flush. |
backgroundColor |
CoreColor? |
Dock bar background colour. |
borderColor |
CoreColor? |
Dock bar top border colour. |
activeColor |
CoreColor? |
Foreground colour for the active dock item (icon + label). |
inactiveColor |
CoreColor? |
Foreground colour for inactive dock items. |
itemIconLabelGapStyle |
CoreGapStyle? |
Gap slot between an item's icon and label. Forwarded straight to
Gap(gapStyle: …)
(Flutter) and to the label's
margin-top
(Web). Merges on top of [defaultItemIconLabelGapStyle].
|
labelTextStyle |
CoreTextStyle? |
Item label text style override (applies to both active and inactive).
Deliberately has no
default*
— the label's baseline is already decided, under the per-state names.
[defaultActiveLabelStyle] and [defaultInactiveLabelStyle] carry the
labelSmall
role plus the weight that distinguishes the two states, and this slot merges
over
whichever of them applies. A constant would land on both states, so every field it names would collapse the active/inactive distinction for that field — a default weight here erases the semiBold-vs-medium contrast that tells a dock user which item is current. On Web it would also change where the typography lives: the resolver passes this as the override argument of
emitTypography(merged.labelTextStyle, labelDefault)
, which emits the role on a
text-{role}
class and only an
explicit
overlay inline. Non-null means every dock item gets inline typography shadowing that class.
|
activeLabelStyle |
CoreTextStyle? |
Active-item label text style override. Merged on top of [defaultActiveLabelStyle]; [labelTextStyle] then layers on top of the result (applies to both states). |
inactiveLabelStyle |
CoreTextStyle? |
Inactive-item label text style override. Merged on top of [defaultInactiveLabelStyle]; [labelTextStyle] then layers on top of the result (applies to both states). |
iconStyle |
CoreIconStyle? |
Item icon style override (size + colour). |
clickableStyle |
CoreClickableStyle? |
Nested [CoreClickableStyle] slot for the
Clickable
composed around each tappable dock item (press scale / focus ring / durations / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the remaining defaults.
|
indicatorColor |
CoreColor? |
Fill of the pill drawn behind the active item. When null, falls back to [defaultIndicatorColor].
CoreColor.transparent
turns the pill off.
|
indicatorRadius |
CoreBorderRadius? |
Corners of the active pill. When null, falls back to [defaultIndicatorRadius]. |
indicatorPadding |
CoreEdgeInsets? |
How far the active pill extends past the item's content on each side. When null, falls back to [defaultIndicatorPadding]. The pill takes no layout room of its own, so the bar keeps this much clear above and below the item row ([indicatorClearanceFor]) — the room that lets the pill stay inside the bar when the label grows. |
indicatorSpring |
CoreMorphSpring? |
The tempo the pill travels between items at when the active item changes. When null, falls back to [defaultIndicatorSpring]. The travel itself is the morph engine's — both platforms hand the departing and arriving pills to
morphShapeAt
and carve the same Core outline — so this is the only knob on it.
|
CoreDockItemData<W>#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
icon | W | 필수 | 아이콘 위젯/컴포넌트 |
label |
String? |
null | 아이콘 아래 텍스트 (xs/sm에서는 숨김) |
isActive | bool | false | 활성 상태 표시 |
onPressed | VoidCallback? | null | 탭 핸들러 |
사이즈 (Sizes)#
높이는 막대의 최소 높이입니다. 항목이 그 안에 들어가는 동안 막대는 정확히 이 높이이고, 글자가 커져 더 필요하면 막대가 그만큼 자랍니다 (아래 "막대 높이 (Bar Height)" 절 참고).
| 사이즈 | 최소 높이 (px) | 아이콘 (px) | 라벨 표시 |
|---|---|---|---|
xs | 48 | 20 | ❌ |
sm | 56 | 22 | ❌ |
md (기본) | 64 | 24 | ✅ |
lg | 72 | 28 | ✅ |
xl | 80 | 32 | ✅ |
동작 스펙 (Behavior)#
-
items를 받은 순서로 표시합니다. item의
onPressed는 탭 · Enter · Space에서 실행하고, null이면 표시용 항목입니다. Dock이 라우팅하거나isActive를 자동 변경하지 않습니다. -
isActive로 활성 아이콘·라벨·인디케이터를 표시합니다. 활성 인덱스가 바뀌고 항목 수가 같으면 인디케이터가 spring 기반 morph로 이동하며 reduced-motion에서는 이동을 생략합니다. size가 라벨 표시 여부와 치수를 정합니다. 바닥 고정·안전영역·내비게이션 스택은 호출부가 구성합니다. 그룹 전체 비활성 플래그는 없습니다.
막대 높이 (Bar Height)#
CoreDockStyle.height(사이즈별 48 · 56 · 64 · 72 · 80)는 막대의 최소 높이입니다.
- 항목 행 — 아이콘 · 라벨, 그리고 활성 항목 둘레의 인디케이터가 위아래로 더 차지하는 여유 — 이 최소 높이 안에 들어가는 동안 막대는 정확히 그 높이이고, 항목은 세로 가운데에 놓입니다. 글자 배율 1 의 기본 구성은 모든 사이즈에서 여기에 해당합니다.
-
OS 글자 크기를 키우거나, 더 큰 아이콘 · 라벨 스타일을 쓰거나, 인디케이터 여백(
indicatorPadding)을 키워 행이 더 필요하면 막대가 행만큼 자랍니다. 라벨의 글자 배율과 줄바꿈은 그대로 두고 줄임표도 쓰지 않으므로, 커진 글자는 잘리지 않고 막대가 그만큼 높아집니다. -
인디케이터는 레이아웃 자리를 차지하지 않고 항목 둘레로 겹쳐 그려지므로, 막대는 그 여백(
indicatorPadding위·아래 중 큰 값)을 항목 위아래에 비워 둡니다. 그래야 글자가 커져도 인디케이터가 막대 밖으로 나가지 않으며, 활성 항목이 바뀌어도 막대 높이는 변하지 않습니다.
왜 자라는가: OS 글자 크기는 라벨만 키우고 아이콘과 이 높이는 그대로 둡니다. 높이를 고정하면 사용자가 키워 달라고 한 글자를 막대가 잘라 냅니다(WCAG 1.4.4 는 200% 확대에서도 콘텐츠와 기능의 손실이 없을 것을 요구합니다). 글자 배율을 묶거나 라벨을 감추면 그 정보를 포기해 높이 숫자 하나를 지키는 셈이고, 막대가 자라는 비용은 몇 픽셀입니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
모든 항목에 onPressed를 지정하고 3~5개 최상위 탭으로 제한
Dock(
items: [
CoreDockItemData(
icon: Icon(LucideIcons.house),
label: 'Home',
isActive: true,
onPressed: () => goToHome(),
),
CoreDockItemData(
icon: Icon(LucideIcons.search),
label: 'Search',
onPressed: () => goToSearch(),
),
],
)
onPressed가 null인 아이템은 양 플랫폼 모두 아무 역할도 갖지 않아 탭도 안 되고 스크린 리더에도 안내되지 않습니다.
❌ Don't#
label 없이 아이콘만 넣지 않기
// ❌ label 없는 아이콘만 — md/lg/xl 에서도 이름 없는 버튼으로 읽힘
CoreDockItemData(
icon: Icon(LucideIcons.settings),
onPressed: openSettings,
)
Icon 자체는 접근 가능한 이름을 갖지 않아, label이 없으면 스크린 리더가 "이름 없는 버튼"으로 읽습니다.
✅ Do#
라벨이 필요한 탭이면 md 이상 사이즈를 선택
Dock(
size: CoreDockSize.md,
items: items,
)
xs/sm은 라벨을 자동으로 숨기므로, 아이콘만으로 뜻이 분명하지 않은 탭에는 md 이상을 사용해야 사용자가 각 탭의 의미를 알 수 있습니다.
❌ Don't#
개수·위치 안내가 필요한 곳에 쓰지 않기
// ❌ 6개 이상의 아이템 — "3개 중 2번째" 같은 위치 안내가 없음
Dock(items: sixOrMoreItems)
아이템이 list/tablist로 묶여 있지 않아 개수나 위치가 스크린 리더에 안내되지 않습니다. 3~5개의 최상위 탭에만 사용하세요.
접근성 (Accessibility)#
역할#
루트는 양 플랫폼 모두 navigation 랜드마크입니다. Flutter 는 CoUISemantics(role: .navigation, container: true)
로, Web 은 루트 <div> 의 role="navigation" 으로 emit 합니다.
랜드마크 이름은 양 플랫폼 모두 semanticLabel 이고, 넘기지 않으면 로케일 기본값 dockLabel("Dock" / "독")로 채워집니다. Web 에서는 명시한
semanticLabel 과 caller 가 넘긴 attributes 가 이 기본값을 덮습니다. Flutter 의 navigation role 은 이름이 있을 때만 트리에 올라가므로, 이 폴백이 곧 랜드마크가 항상 announce 되는 조건입니다.
아이템은 onPressed 여부로 갈립니다. 눌리는 아이템은 Flutter Semantics(button: true, selected: item.isActive), Web 은 합성된
Clickable 의 role="button" 이며 활성 아이템에는 aria-current="page" 가 추가됩니다.
onPressed 가 null 인 아이템은 양 플랫폼 모두 아무 역할도 갖지 않습니다.
키보드#
Dock 자체는 키를 처리하지 않습니다. 아래 키는 눌리는 아이템에 합성된 Clickable 이 제공합니다.
| 키 | 동작 |
|---|---|
Tab / Shift+Tab |
다음/이전 아이템으로 이동 — 눌리는 아이템마다 독립적인 탭 스톱 |
Enter | 포커스된 아이템 활성화 |
Space | 포커스된 아이템 활성화 |
화살표 키 이동은 없습니다. 툴바/탭 계열에서 흔한 roving tabindex(한 번 진입 후 화살표로 이동) 패턴이 구현되어 있지 않으므로, 아이템이 N 개면 탭 스톱도 N 개입니다.
포커스 관리#
포커스는 아이템 단위로만 존재하며 Clickable 에서 옵니다 — Flutter 는 FocusNode + FocusOutline
링, Web 은 tabindex="0" + :focus-visible 링입니다. 누를 수 없는 아이템은 포커스를 받지 않습니다.
Dock 수준의 포커스 트랩·복원·autofocus·FocusTraversalGroup 은 없습니다.
스크린 리더#
- Flutter: navigation 랜드마크(지정한 라벨 또는 "Dock" / "독")에 진입한 뒤 각 아이템의 라벨을 읽습니다. 눌리는 아이템은 버튼으로, 활성 아이템은 선택됨(selected)으로 안내됩니다.
- Web: 같은 이름의 navigation 랜드마크에 진입한 뒤 각 아이템을 읽습니다. 눌리는 아이템은 버튼으로, 활성 아이템은 현재 페이지(current page)로 안내됩니다.
알려진 제약#
-
활성 상태 신호가 플랫폼마다 다릅니다 — Flutter
selectedvs Webaria-current="page". 두 플랫폼에서 리더가 읽는 문구가 같지 않습니다. -
아이템이
list/tablist로 묶여 있지 않아 "3개 중 2번째" 같은 개수·위치가 안내되지 않습니다. - 화살표 키 이동과 roving tabindex 가 없어 아이템 수만큼 탭 스톱이 생깁니다.
-
누를 수 없는 아이템(
onPressed == null)은 역할이 없고, 그isActive상태는 Flutter 에서만 안내되고 Web 에서는 아무것도 안내되지 않습니다. -
라벨이 숨는 사이즈(
xs/sm)와label이 없는 아이템은 라벨 문자열이 접근성 트리에 들어가지 않습니다.Icon자체는 이름을 갖지 않으므로 그 아이템은 이름 없는 버튼으로 읽힙니다.
전역으로 적용되는 항목(감소된 모션·고대비·강제 색상·최소 터치 타겟 등)은 전역 접근성 축을 참고하세요.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Dock | Dock |
| 아이템 타입 | CoreDockItemData<Widget> |
CoreDockItemData<Component> |
| 활성 의미 | Semantics(selected:) |
눌리는 활성 항목의 aria-current="page" |
| 클릭·포커스 | 합성된 Clickable | 합성된 Clickable |
| 인디케이터 측정 | RenderBox 좌표·GlobalKey | DOM rect·요소 id |
| 렌더링 | Container + Row(spaceAround) |
<div> flex + 토큰 기반 |
| 막대 높이 | Container(constraints: BoxConstraints(minHeight:)) 안의 내용 크기 Stacks |
루트 min-height (rem) |
| 인디케이터 여유 | 항목 행을 감싼 세로 Padding |
각 항목(탭 가능하면 그 Clickable 루트)의 세로 margin |
관련 컴포넌트 (Related Components)#
- NavigationBar: 상단 내비게이션
- Tabs: 컨텐츠 내부 탭