Dock#
모바일/태블릿 앱 하단에 고정되는 아이콘 + 라벨 기반 내비게이션 바입니다. 5단계 사이즈(xs/sm/md/lg/xl)를 지원하며
xs/sm에서는 라벨이 자동으로 숨겨집니다.
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'),
],
);
}
}
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;
}
}
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;
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 앱의 최상위 3~5개 탭 사이를 이동할 때
- 현재 위치를 시각적으로 표시해야 할 때 (active item)
대신 다른 컴포넌트를 사용하세요:
NavigationBar: 데스크톱 상단 전역 내비게이션Tabs: 컨텐츠 내부의 탭 전환
기본 사용법 (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;
}
}
Props / Parameters#
Dock#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreDockItemData<W>> |
필수 | 렌더링할 아이템 목록 |
size |
CoreDockSize? |
md |
xs/sm/md/lg/xl 높이 스텝 |
semanticLabel |
String? |
'navigation' |
스크린리더 landmark label |
dockStyle |
CoreDockStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 표 참고) |
CoreDockStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
height |
double? |
Dock bar height (logical px, pre-scaling). When null, falls back to [defaultsBySize] for the active [CoreDockSize]. |
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.
|
CoreDockItemData<W>#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
icon | W | 필수 | 아이콘 위젯/컴포넌트 |
label |
String? |
null | 아이콘 아래 텍스트 (xs/sm에서는 숨김) |
isActive | bool | false | 활성 상태 표시 |
onPressed | VoidCallback? | null | 탭 핸들러 |
사이즈 (Sizes)#
| 사이즈 | 높이 (px) | 아이콘 (px) | 라벨 표시 |
|---|---|---|---|
xs | 48 | 20 | ❌ |
sm | 56 | 22 | ❌ |
md (기본) | 64 | 24 | ✅ |
lg | 72 | 28 | ✅ |
xl | 80 | 32 | ✅ |
사용 가이드라인 (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)#
역할 / Semantics#
루트는 양 플랫폼 모두 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> |
| backgroundColor | Color? | String? (CSS) |
| 클릭 커서 | MouseRegion(cursor: click) |
cursor-pointer class |
| 렌더링 | Container + Row(spaceAround) |
<div> flex + 토큰 기반 |
관련 컴포넌트 (Related Components)#
- NavigationBar: 상단 내비게이션
- Tabs: 컨텐츠 내부 탭