NavigationBar
가로 바 / 세로 레일 / 사이드바 세 가지 모드를 지원하는 통합 네비게이션 컨테이너 컴포넌트
목차
NavigationBar는 주요 섹션 간 이동을 위한 통합 네비게이션 컨테이너입니다. mode 한 가지로 가로 바(bar), 세로 레일(rail), 확장 사이드바(sidebar) 세 가지 레이아웃을 모두 표현하며, 아이템은 단일
CoreNavigationBarItem 데이터 모델로 구성합니다.
sidebar모드는 0.164 부터 예고(@Deprecated)되어 있습니다. 앱의 옆 내비게이션은 헤더 · 검색 · 구역 · 그룹 · 레일을 갖춘 Sidebar를 쓰세요. 예고 기간 동안sidebar모드는 지금처럼 그려집니다.
기본 사용법 (Basic Usage)#
NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
)
NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 NavigationBar 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.surfaceContainer처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(surfaceContainer ==
CoreColors.surfaceContainer) 어느 컴포넌트에서 써도 뜻이 갈리지 않으며, 위 예시처럼 withStyle 뒤에 이어붙일 수도 있습니다.
class NavigationBarChainExample extends StatefulWidget {
const NavigationBarChainExample({super.key});
@override
State<NavigationBarChainExample> createState() => _NavigationBarChainExampleState();
}
class _NavigationBarChainExampleState extends State<NavigationBarChainExample> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.bell),
label: 'Alerts',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
)
.withStyle(
const CoreNavigationBarStyle(
itemSpacing: CoreSpace.space12,
itemPadding: CoreEdgeInsets.all(CoreSpace.space12),
itemRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
)
.surfaceContainer;
}
}
class NavigationBarChainExample extends StatefulComponent {
const NavigationBarChainExample({super.key});
@override
State<NavigationBarChainExample> createState() => _NavigationBarChainExampleState();
}
class _NavigationBarChainExampleState extends State<NavigationBarChainExample> {
int _selectedIndex = 0;
@override
Component build(BuildContext context) {
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.bell),
label: 'Alerts',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
)
.withStyle(
const CoreNavigationBarStyle(
itemSpacing: CoreSpace.space12,
itemPadding: CoreEdgeInsets.all(CoreSpace.space12),
itemRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
)
.surfaceContainer;
}
}
Live Preview#
코드 보기
class NavigationBarDefaultExample extends StatefulComponent {
const NavigationBarDefaultExample({super.key});
@override
State<NavigationBarDefaultExample> createState() =>
_NavigationBarDefaultExampleState();
}
class _NavigationBarDefaultExampleState
extends State<NavigationBarDefaultExample> {
int _selectedIndex = 0;
@override
Component build(BuildContext context) {
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.bell),
label: 'Alerts',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
);
}
}
class NavigationBarDefaultExample extends StatefulWidget {
const NavigationBarDefaultExample({super.key});
@override
State<NavigationBarDefaultExample> createState() =>
_NavigationBarDefaultExampleState();
}
class _NavigationBarDefaultExampleState
extends State<NavigationBarDefaultExample> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.house),
label: 'Home',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.search),
label: 'Search',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.bell),
label: 'Alerts',
),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.user),
label: 'Profile',
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 모바일 앱 하단의 주요 3~5개 섹션 이동(
bar모드) - 데스크톱/태블릿 측면의 컴팩트 네비게이션(
rail모드) - 라벨이 강조된 풀-너비 사이드바 네비게이션(
sidebar모드)
대신 다른 컴포넌트를 사용하세요:
NavigationMenu: 드롭다운 패널이 있는 가로 메가 메뉴가 필요할 때Tabs: 동일 화면 내에서 콘텐츠 탭을 전환할 때
Props / Parameters#
W는 목적지의 합성 아이콘 타입으로 Flutter의 Widget, Web의 Component입니다. 인덱스는 0부터 시작하며 destination 항목만 셉니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreNavigationBarItem<W>> |
필수 | 네비게이션 엔트리 목록 |
selectedIndex |
int? |
null |
현재 선택된 destination 인덱스 (destination만 카운트) |
onSelected |
void Function(int)? |
null |
destination 선택 핸들러 |
mode |
CoreNavigationBarMode |
bar |
레이아웃 모드 (bar / rail / sidebar) |
variant |
CoreNavigationBarVariant |
standard |
시각 변형 |
labelMode |
CoreNavigationLabelMode? |
모드별 기본값 | 라벨 표시 시점 (all / selected / none) |
labelPosition |
CoreNavigationLabelPosition? |
모드별 기본값 | 아이콘 대비 라벨 위치 (bottom / top / start / end) |
alignment |
CoreNavigationAlignment |
center |
메인 축 정렬 |
overflow |
CoreNavigationOverflow |
ellipsis |
라벨 오버플로 처리 |
selectionMark |
CoreNavigationSelectionMark |
none |
라벨 색 · 굵기 말고 선택을 알리는 표시.
dot
은 라벨을 아이콘 위아래로 쌓는 목적지(바 · 레일)의 내용 위에 작은 점을 그립니다 — 점의 모양은
navigationBarStyle.selectionMarkStyle
(
Badge
dot), 내용과의 간격은
selectionMarkGapStyle
|
indicator |
CoreNavigationSelectionIndicator? |
모드별 기본값 |
선택 표시 방식.
fill
은
primaryContainer
로 — 쌓인 목적지는 아이콘 뒤 알약에, 라벨을 옆에 둔 행은 행의 면에 — 칠하고,
color
는 면 없이 아이콘과 라벨의 색만 바꿉니다. 바는
color
, 레일과 사이드바는
fill
|
header |
W? |
null |
목적지 앞, 메인 축 시작에 놓이는 내용(앱 로고 등). 바의 면·선·패딩 안에 들어가고 목적지가 스크롤돼도 제자리에 있습니다 |
footer |
W? |
null |
목적지 뒤, 메인 축 끝에 놓이는 내용(계정·플랜 카드 등). 바의 면·선·패딩 안에 들어갑니다 |
navigationBarStyle |
CoreNavigationBarStyle? |
null |
크롬 / 치수 스타일 오버라이드 |
CoreNavigationBarItem#
| 팩토리 | 설명 |
|---|---|
CoreNavigationBarItem.destination({icon, label, badge, tooltip, enabled, showDot}) |
선택 가능한 네비게이션 목적지.
badge
는 개수 같은 표시를 받는
W?
슬롯이고,
showDot
은 개수 없이 새 소식이 있다는 점을 아이콘 끝 위 모서리에 그립니다(
badge
가 있으면
badge
가 이깁니다)
|
CoreNavigationBarItem.label(String) | 비선택 섹션 헤더 라벨 |
CoreNavigationBarItem.divider() | 구분선 |
CoreNavigationBarItem.gap(double) | 빈 간격 |
목적지 하나는 공개 위젯 NavigationBarItem 으로 그려집니다 — 바가
목적지마다 그 위젯을 합성하므로, 바 밖에서(Drawer 안의 사이드바 등) 손으로 놓은 항목도 바의 목적지와
똑같이 보이고 동작합니다.
스타일 시스템 (Style System)#
CoreNavigationBarStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
surfaceStyle |
CoreSurfaceStyleId? |
Material override. Null inherits; defaultStyle explicitly removes glass. Deliberately no default: null inherits the active theme material axis; a fixed default would override that axis before the resolver sees it. |
backgroundColor |
CoreColor? |
Background colour of the navigation container surface. |
padding |
CoreEdgeInsets? |
Outer padding of the navigation container. A docked [CoreNavigationBarMode.bar] (no [barMargin]) adds the device's bottom safe area — the home indicator — to the bottom of this padding on both platforms: Flutter adds
MediaQuery.paddingOf(context).bottom
, Web
env(safe-area-inset-bottom)
. The bar consumes the inset itself, so its face paints under the indicator and the destinations sit above it. Do not wrap it in a
SafeArea
as well; if an ancestor
SafeArea
has already consumed the inset, the bar reads zero and the inset is not added twice. A detached bar (a [barMargin], or the glass bar) does not take the inset into its padding.
|
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.
null
leaves the bar 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. |
barBorderColor |
CoreColor? |
Colour of the bar's content-facing hairline. Drawn on the edge that faces the navigated content — the top edge of a horizontal bar, the trailing (logical end) edge of a rail / sidebar. |
barBorderWidth |
double? |
Thickness of the bar's content-facing hairline (logical px). |
itemSpacing |
double? |
Gap between adjacent navigation items (logical px). The baseline lives in [defaultsByMode], not in a
defaultItemSpacing
constant. There used to be one, and it was a single
space8
for all three modes — which
bar
does not want: its items sit flush at
0
. A scalar default cannot state that, so it cannot come back; read
defaultsByMode[mode].spacing
instead.
|
itemPadding |
CoreEdgeInsets? |
Inner padding of a navigation item. |
itemRadius |
CoreBorderRadius? |
Corner radius of a navigation item. |
itemMinHeight |
double? |
Minimum height of a navigation item (logical px). |
iconOnlyItemMinHeight |
double? |
Minimum painted height of a stacked destination that hides its label (logical px). Defaults to [defaultIconOnlyItemMinHeight]; a stated [itemMinHeight] reaches these destinations too when this is
null
.
|
iconOnlyItemMinWidth |
double? |
Minimum painted width of a stacked destination that hides its label (logical px). Defaults to [defaultIconOnlyItemMinWidth]. |
touchTargetSize |
double? |
Smallest region, on either axis, that accepts a pointer around a stacked destination (logical px) — hit testing only, nothing painted moves. Defaults to [defaultTouchTargetSize]. |
labelSpacing |
double? |
Native flex
spacing
between an item's icon and its label — passed directly to
Row.spacing
/
Column.spacing
(Flutter) and the inner flex
gap
(Web). Not wrapped in
Gap
(logical px).
|
iconStyle |
CoreIconStyle? |
Navigation item icon style override. size defers to [defaultIconStyle]. |
disabledIconStyle |
CoreIconStyle? |
Icon override for a disabled destination, layered over the resolved [iconStyle]. Defaults to [defaultDisabledIconStyle]. |
badgeIndicatorStyle |
CoreIndicatorStyle? |
Nested [CoreIndicatorStyle] slot for the composed
Indicator
that lays a destination's
badge
over its icon in the bar and the rail. Merged on top of [defaultBadgeIndicatorStyle] and raw-forwarded — the indicator's own resolver fills the rest.
|
selectedBackgroundColor |
CoreColor? |
Background colour of the selected item — a row's face, or the tile behind a stacked destination under the
color
indicator. Under
fill
a stacked destination paints its pill ([selectedIconAnchorColor]) instead and leaves this unread.
|
selectedLabelTextStyle |
CoreTextStyle? |
Label text style override for a selected item. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
selectedForegroundColor
field removed). Defaults to [defaultSelectedLabelTextStyle].
|
hoverBackgroundColor |
CoreColor? |
Background colour applied to an item on hover. Defaults to [defaultHoverBackgroundColor] — transparent, i.e. no swap. Set it to paint a fill on hover INSTEAD of [stateLayer]; the two do not stack, because two mechanisms saying the same state is how this component's hover drifted in the first place. |
stateLayer |
CoreStateLayer? |
Ramp laid over the bar while a destination is hovered, focused or pressed. Defaults to [defaultStateLayer]. |
stateLayerColor |
CoreColor? |
Ink [stateLayer] lays over the bar. Defaults to [defaultStateLayerColor]. |
labelTextStyle |
CoreTextStyle? |
Label text style override for an unselected item. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
foregroundColor
field removed). Defaults to [defaultLabelTextStyle].
|
disabledLabelTextStyle |
CoreTextStyle? |
Label text style override for a disabled item. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
disabledForegroundColor
field removed). Defaults to [defaultDisabledLabelTextStyle].
|
disabledSelectedLabelTextStyle |
CoreTextStyle? |
Label text style override for a destination that is selected AND disabled, layered over [disabledLabelTextStyle]. Defaults to [defaultDisabledSelectedLabelTextStyle]. |
sectionLabelTextStyle |
CoreTextStyle? |
Text style override for a non-selectable section header. The semibold weight and
onSurfaceSubtle
colour live in the [defaultSectionLabelTextStyle] overlay; this slot tweaks them per instance. Defaults to [defaultSectionLabelTextStyle].
|
sectionLabelPadding |
CoreEdgeInsets? |
Inset around a non-selectable section header. Defaults to [defaultSectionLabelPadding]. |
dividerColor |
CoreColor? |
Colour of a navigation divider line. |
dividerThickness |
double? |
Thickness of a navigation divider line (logical px). |
duration |
Duration? |
Colour-transition duration for item hover / selection. |
clickableStyle |
CoreClickableStyle? |
Nested [CoreClickableStyle] slot for the composed destination
Clickable
(press scale / focus ring / cursor / keyboard activation). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
|
selectionMarkStyle |
CoreBadgeStyle? |
Nested [CoreBadgeStyle] slot for the selection-mark dot — merged on top of [defaultSelectionMarkStyle] and raw-forwarded to the composed
Badge(form:.dot)
. Read only while the bar's
selectionMark
is
dot
.
|
selectionMarkGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the space between the selection-mark dot and the content under it — merged on top of [defaultSelectionMarkGapStyle] and raw-forwarded to
Gap
.
|
disabledSelectionMarkStyle |
CoreBadgeStyle? |
Nested [CoreBadgeStyle] overlay on the selection-mark dot of a selected, disabled destination — merged over the resolved [selectionMarkStyle] and [defaultDisabledSelectionMarkStyle]. |
selectedIconStyle |
CoreIconStyle? |
Icon override for the selected destination, layered over the resolved [iconStyle] (size from there, colour from here). Defaults to [defaultSelectedIconStyle], with the indicator's own colour ([defaultsByIndicator]) between the two. |
hoverForegroundColor |
CoreColor? |
Foreground (label and icon) colour of a hovered, unselected destination. Defaults to [defaultHoverForegroundColor] — the transparent sentinel, which leaves the resting foreground in place; the
color
indicator states
onSurface
in [defaultsByIndicator].
|
selectedIconAnchorColor |
CoreColor? |
Pill behind the icon of a selected destination that stacks its label (shown under the icon or hidden), under the
fill
indicator. Defaults to [defaultSelectedIconAnchorColor].
|
selectedIconAnchorIconStyle |
CoreIconStyle? |
Icon override drawn on [selectedIconAnchorColor]. Defaults to [defaultSelectedIconAnchorIconStyle]. |
disabledSelectedBackgroundColor |
CoreColor? |
Face of a destination that is selected AND disabled and paints its selection on the tile (a row, or a destination with no pill). Defaults to [defaultDisabledSelectedBackgroundColor], with the indicator's own ([defaultsByIndicator]) between the two. |
disabledSelectedIconAnchorColor |
CoreColor? |
Pill behind the icon of a stacked destination that is selected AND disabled, under the
fill
indicator. Defaults to [defaultDisabledSelectedIconAnchorColor].
|
iconAnchorPadding |
CoreEdgeInsets? |
Inset of the box around a stacked destination's icon — the box the selected pill fills. Defaults to [defaultIconAnchorPadding]. |
iconAnchorRadius |
CoreBorderRadius? |
Corners of the box around a stacked destination's icon. Defaults to [defaultIconAnchorRadius]. |
notificationDotStyle |
CoreBadgeStyle? |
Nested [CoreBadgeStyle] slot for a destination's notification dot (
showDot
) — merged on top of [defaultNotificationDotStyle] and raw-forwarded to the composed
Badge(form:.dot)
.
|
notificationDotIndicatorStyle |
CoreIndicatorStyle? |
Nested [CoreIndicatorStyle] slot for the
Indicator
that lays the notification dot over the icon's top-end corner — merged on top of [defaultNotificationDotIndicatorStyle] and raw-forwarded.
|
CoreNavigationBarStyle 변형별 기본값 (CoreNavigationBarVariantStyle)#
| 필드 | standard |
|---|---|
backgroundColor | surfaceContainerLow |
모드 (Modes)#
Bar#
가로 바 레이아웃입니다. 모바일 앱 하단 네비게이션에 적합합니다.
NavigationBar(mode: CoreNavigationBarMode.bar, items: [...])
아래 안전 영역 (Bottom safe area)
화면 아래에 붙은 바는 기기의 아래 안전 영역(홈 인디케이터)을 스스로 받습니다. 바의 아래 패딩 8 에 기기의 인셋이 더해져서, 면은 인디케이터 밑까지 칠해지고 목적지는 그 위에 앉습니다. 인셋은 기기마다 다르므로 스타일 값이 아닙니다.
-
Flutter —
MediaQuery.paddingOf(context).bottom을 더합니다.viewPadding이 아니라padding인 이유는 둘입니다. 키보드가 인디케이터를 덮으면padding은 0 이 되고, 바깥SafeArea가 이미 소비한 인셋도 0 으로 읽힙니다. -
Web —
padding-bottom: calc(8px + env(safe-area-inset-bottom))를 냅니다. 인셋이 없는 곳(데스크톱,viewport-fit=cover를 쓰지 않은 페이지)에서env()는 0 입니다.
규칙은 바가 인셋을 소비한다 입니다. 바를 SafeArea 로 다시 감싸지 마세요. 감싸면 면이 인디케이터 위에서 끊기고, 그 아래로 페이지 바탕이 보입니다. 바깥
SafeArea 가 이미 인셋을 소비했다면 바는 0 을 읽으므로 두 번 더해지지는 않습니다. 떠 있는 바(barMargin 을 준 바, 글래스 바)는 인셋을 패딩에 넣지 않습니다. Flutter 의 글래스 바는 자기
SafeArea 안에 놓이고, barMargin 으로 띄운 바의 아래 공간은 앱이 정합니다.
Rail#
세로 컴팩트 레일 레이아웃입니다. 측면 네비게이션에 적합합니다.
NavigationBar(mode: CoreNavigationBarMode.rail, items: [...])
Sidebar#
확장 세로 사이드바 레이아웃입니다. 섹션 라벨과 구분선으로 계층 구조를 표현합니다.
NavigationBar(
mode: CoreNavigationBarMode.sidebar,
items: const [
CoreNavigationBarItem.label('Main'),
CoreNavigationBarItem.destination(label: 'Dashboard'),
CoreNavigationBarItem.divider(),
CoreNavigationBarItem.label('Account'),
CoreNavigationBarItem.destination(label: 'Settings'),
],
)
모드별 밀도 (Density per mode)#
하단 바와 데스크톱 사이드바는 같은 컴포넌트의 다른 밀도입니다. 바와 레일은 아이콘 아래 12px 캡션을 단 64 높이 칸을, 사이드바는 20px 아이콘과 14px 라벨을 나란히 놓은 40 높이 행을 그립니다. 모드마다 다른 값은
CoreNavigationBarStyle.defaultsByMode 의 모드 칸이 정하고, 칸을 비운 모드는 평평한 default* 값(바 · 레일 값)을 씁니다. 두 플랫폼 모두 인스턴스 · 테마 값 > 모드 칸 > 평평한 기본값 순서로 읽습니다.
| 항목 | bar | rail | sidebar |
|---|---|---|---|
| 바 패딩 | 세로 8 · 가로 12 (+ 아래 안전 영역) | 8 | 세로 16 · 가로 12 |
| 바 면 | surface |
surfaceContainerLow |
surfaceContainerLow |
| 항목 사이 | 0 | 8 | 2 |
| 항목 최소 높이 | 64 (라벨을 숨기면 40) | 64 (라벨을 숨기면 40) | 40 |
| 항목 패딩 | 세로 6 · 가로 4 | 세로 6 · 가로 4 | 세로 10 · 가로 12 |
| 항목 모서리 | 10 | 10 | 10 |
| 아이콘 | 24 (48×28 상자 안) | 24 (48×28 상자 안) | 20 |
| 아이콘 ↔ 라벨 | 4 | 4 | 10 |
| 라벨 역할 | labelSmall |
labelSmall |
labelLarge |
| 라벨 표시 | 모두(all) |
모두(all) |
모두(all) |
선택 표시 (indicator) |
color |
fill |
fill |
선택은 세 모드 모두 라벨 굵기(600 / 500)로 알리고, 그 위에 indicator 가 정한 표시를 더합니다 — 아래 선택 표시
를 보세요. 바의 면은 레일과 사이드바가 surfaceContainerLow(화면 틀의 단)이고, 하단 바는 모드 칸이 정한 surface
입니다. 하단 바는 콘텐츠 아래에 붙어 위쪽 선으로 갈리므로, 인디케이터 밑까지 다른 단을 깔 필요가 없습니다. 선은 콘텐츠를 향한 한 변에만 그립니다 — 하단 바는 위, 레일과 사이드바는 끝(LTR 에서 오른쪽) 변입니다.
모드와 상관없이 같은 값도 있습니다. 꺼진 목적지의 글자와 아이콘은 disabledContent 를 쓰고, 꺼진 라벨의 글자는 평소 라벨의 labelTextStyle
이 아니라 disabledLabelTextStyle 에서 옵니다 — 평소 라벨에 준 글자 스타일은 꺼진 라벨에 닿지 않습니다. 꺼진 목적지가 현재 위치(선택)이면 선택 표시를 흐리게 남깁니다 —
fill 의 알약(줄 배치는 바탕)은 disabledContainer, 라벨은 disabledContent 에 선택 굵기(600)로, 선택 점(selectionMark: dot)은
disabledContent 로 그립니다(disabledSelectedIconAnchorColor · disabledSelectedBackgroundColor
· disabledSelectedLabelTextStyle · disabledSelectionMarkStyle). 보조 기술에는 선택을 알리지 않습니다. 그룹 제목은
labelSmall 600 에 onSurfaceSubtle 을 씁니다. 그룹 제목은 목적지의 항목 패딩을 빌리지 않고 자기 패딩(sectionLabelPadding
— 위 18 · 아래 6 · 좌우 10)을 가져서, 위 여백이 아래보다 커야 그룹이 갈립니다.
선택 표시 (Selection indicator)#
선택된 목적지를 어떻게 알릴지는 indicator(CoreNavigationSelectionIndicator)가 정합니다. 주지 않으면 모드의 기본값(CoreNavigationBarMode.defaultIndicator)을 씁니다. 값은
CoreNavigationBarStyle.defaultsByIndicator 의 칸에서 오고, 두 플랫폼 모두 인스턴스 · 테마 값 > 재질 프리셋 > indicator 칸 > 모드 칸 > 평평한 기본값 순서로 읽습니다.
fill (레일 · 사이드바) | color (바) | |
|---|---|---|
| 쌓인 목적지 (라벨을 아래에 두거나 숨김) |
아이콘 뒤 48×28
primaryContainer
알약, 아이콘
onPrimaryContainer
, 라벨
onSurface
600 — 타일은 칠하지 않음
|
면 없음, 아이콘 · 라벨 primary (라벨 600) |
| 라벨을 옆에 둔 행 | primaryContainer 면, 아이콘 · 라벨 onPrimaryContainer (라벨 600) |
면 없음, 아이콘 · 라벨 primary (라벨 600) |
| hover | onSurface 상태 레이어 3 % |
레이어 없이 아이콘과 라벨이 onSurface 로 진해짐 |
| focus | onSurface 상태 레이어 8 % + 포커스 링 |
onSurface 상태 레이어 8 % + 포커스 링 |
| 선택된 목적지의 상태 | 선택 표시 위에 같은 상태 레이어 | 선택 표시 위에 같은 상태 레이어 |
| 쉬는 아이콘 · 라벨 | onSurfaceSubtle | onSurfaceSubtle |
쉬는 목적지는 onSurfaceSubtle 로 한 단 연하게 두어 선택된 목적지가 한눈에 읽히게 합니다(쉬는 Tabs 와 같은 연함). 그 대가로 바 면 위와 hover · 누름 워시 위에서 쉬는 라벨은 4.5 : 1 아래(라이트 3.8 ~ 4.5)로 내려갑니다 — 디자이너가 받아들인 예외로 대비 검사 기준선에 기록돼 있고, 선택된 목적지와 고대비 모드는 4.5 : 1 을 넘습니다.
쌓인 목적지(바 · 레일)는 선택 여부와 상관없이 아이콘을 자기 상자(iconAnchorPadding 가로 12 · 세로 2, iconAnchorRadius
둥글게) 안에 둡니다. 그래서 선택이 옮겨가도 목적지 폭이 그대로이고, 알약은 그 상자를 칠할 뿐입니다.
fill 은 세 라벨 배치 모두 같은 primaryContainer · onPrimaryContainer 쌍으로 선택을 알립니다. 쌓인 목적지는 그 쌍을 아이콘 둘레 알약에 칠하고 라벨은 바 위의
onSurface 로 두며(M3 하단 바), 라벨을 아이콘 옆에 둔 행(labelPosition: .start / .end)은 행의 면에 칠하고 라벨도
onPrimaryContainer(defaultsByIndicator[fill] 의 selectedRowLabelTextStyle)로 둡니다. 레일을 접었다 펼쳐도 같은 선택 상태로 읽히게 하기 위해서입니다. 행의 면은
selectedBackgroundColor, 알약은 selectedIconAnchorColor 가 정하므로, fill
의 쌓인 목적지는 selectedBackgroundColor 를 읽지 않습니다.
상태 레이어(hover · focus · 누름)는 선택 여부와 상관없이 칠한 것 위에 겹칩니다. 선택된 행은 자기 면 위에, 쌓인 목적지는 타일 위에(알약은 그 앞에) 같은 onSurface
워시를 받고, 포커스에서는 포커스 링도 함께 그립니다.
라벨을 숨긴 쌓인 목적지는 64×40 을 칠합니다(iconOnlyItemMinWidth · iconOnlyItemMinHeight). 포인터를 받는 영역은 그와 따로 가로 · 세로 최소 48 입니다(touchTargetSize) — Flutter 는
TouchTarget 이, Web 은 coui-touch-target 의 ::after 가 히트 영역만 넓히고 레이아웃과 그림은 그대로 둡니다. 바가 목적지 몫으로 주는 폭이 64 보다 좁으면 두 플랫폼 모두 그 몫을 따릅니다. 인스턴스나 재질 프리셋이
itemMinHeight 를 정하면(유리 바의 48) 라벨을 숨긴 목적지도 그 높이를 씁니다. 하단 바의 기본은 라벨을 모두 보이는 것(all)이라 목적지는 64, 바는 세로 8 + 64 + 8(안전 영역 별도)입니다.
labelMode: .none 을 직접 준 하단 바만 세로 8 + 40 + 8 이 됩니다 — 라벨을 숨긴 40 은 접힌 레일 · 좁은 자리를 위한 높이입니다.
NavigationBar(
mode: CoreNavigationBarMode.rail,
header: const AppLogo(),
items: const [
CoreNavigationBarItem.destination(icon: Icon(LucideIcons.house), label: 'Home'),
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.inbox),
label: 'Inbox',
showDot: true,
),
],
)
배지와 아래 칸 (Badge · Footer)#
CoreNavigationBarItem.destination(badge:) 는 개수를 보여 주는 Badge 를 받습니다. 사이드바에서는 행의 끝(라벨 뒤 빈칸)에, 바와 레일에서는 아이콘의 오른쪽 위 모서리에 놓입니다. 배지의 글자는 목적지 이름 뒤에 함께 읽힙니다(Notifications 3).
footer 는 목적지 뒤 메인 축 끝에 놓이는 내용입니다. 바의 면 · 선 · 패딩 안에 들어가므로 호출부가 바를 다시 그리지 않습니다. 부모가 바의 길이를 정해 주면(높이를 지정하거나 셀을 늘리면) 아래 끝(가로 바에서는 오른쪽 끝)에 붙고, 목적지가 바보다 크면 목적지가 스크롤되고 아래 칸은 제자리에 남습니다. 길이를 정해 주지 않으면 바는 목적지와 아래 칸을 합친 만큼만 차지합니다.
NavigationBar(
mode: CoreNavigationBarMode.sidebar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
items: const [
CoreNavigationBarItem.destination(
icon: Icon(LucideIcons.bell),
label: 'Notifications',
badge: Badge(
form: CoreBadgeForm.count,
tone: CoreBadgeTone.info,
variant: CoreBadgeVariant.filled,
size: CoreComponentSize.xs,
child: Text('3'),
),
),
],
footer: const Text('Free plan · 3 of 5 seats').labelMedium.onSurfaceVariant,
)
글자 탭과 선택 점 (Text tabs · Selection mark)#
아이콘 없이 라벨만 둔 탭은 icon 을 비우고 labelMode: .all 로 만듭니다. 선택은 라벨의 색과 굵기로 알리고, selectionMark: .dot
을 주면 선택된 탭의 라벨 위에 작은 점이 하나 더 붙습니다.
코드 보기
class NavigationBarTextTabsExample extends StatefulComponent {
const NavigationBarTextTabsExample({super.key});
@override
State<NavigationBarTextTabsExample> createState() => _NavigationBarTextTabsExampleState();
}
class _NavigationBarTextTabsExampleState extends State<NavigationBarTextTabsExample> {
int _selectedIndex = 0;
@override
Component build(BuildContext context) {
// Bare-label tabs: no icons, every label shown, and a dot over the
// selected one. Every tab keeps the dot's room, so the labels stay level.
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
selectionMark: CoreNavigationSelectionMark.dot,
items: const [
CoreNavigationBarItem.destination(label: '홈'),
CoreNavigationBarItem.destination(label: '지도'),
CoreNavigationBarItem.destination(label: '채팅'),
CoreNavigationBarItem.destination(label: '내 정보'),
],
);
}
}
class NavigationBarTextTabsExample extends StatefulWidget {
const NavigationBarTextTabsExample({super.key});
@override
State<NavigationBarTextTabsExample> createState() => _NavigationBarTextTabsExampleState();
}
class _NavigationBarTextTabsExampleState extends State<NavigationBarTextTabsExample> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
// Bare-label tabs: no icons, every label shown, and a dot over the
// selected one. Every tab keeps the dot's room, so the labels stay level.
return NavigationBar(
mode: CoreNavigationBarMode.bar,
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
selectionMark: CoreNavigationSelectionMark.dot,
items: const [
CoreNavigationBarItem.destination(label: '홈'),
CoreNavigationBarItem.destination(label: '지도'),
CoreNavigationBarItem.destination(label: '채팅'),
CoreNavigationBarItem.destination(label: '내 정보'),
],
);
}
}
NavigationBar(
selectedIndex: _selectedIndex,
onSelected: (index) => setState(() => _selectedIndex = index),
labelMode: CoreNavigationLabelMode.all,
selectionMark: CoreNavigationSelectionMark.dot,
items: const [
CoreNavigationBarItem.destination(label: '홈'),
CoreNavigationBarItem.destination(label: '지도'),
CoreNavigationBarItem.destination(label: '채팅'),
CoreNavigationBarItem.destination(label: '내 정보'),
],
)
- 모든 탭이 점의 자리를 가집니다. 점은 선택된 탭에만 보이지만 나머지 탭도 같은 높이를 비워 두므로, 선택이 바뀌어도 라벨이 들썩이지 않고 한 줄로 맞습니다.
-
점은
Badge의 dot 입니다 — 기본은 4px, 선택된 라벨과 같은onSurface, 내용과 6 떨어집니다. 색 · 크기는selectionMarkStyle에, 간격은selectionMarkGapStyle에 줍니다. 선택된 라벨 색을 바꾸면 점 색도 함께 바꿔야 한 신호로 읽힙니다 — 점이 라벨 색을 스스로 따라가지는 않습니다(유리 바는 둘 다primary로 정해 둡니다). -
라벨을 아이콘 옆에 두는 행에는 점이 없습니다. 사이드바처럼 라벨이 아이콘 옆에 놓이면(
labelPosition: .start/.end) 점을 얹을 위쪽이 없고, 선택된 행의 면이 이미 선택을 알립니다.
동작 스펙 (Behavior)#
인터랙션#
- 선택: destination 활성화 시
onSelected(index)호출 (destination만 인덱싱) - 호버: destination 호버 시 배경색 약한 강조
- 키보드: 포커스된 destination에서
Enter/Space로 활성화
애니메이션#
- 활성/호버 전환: 배경·전경색 변경 150ms ease-in-out
-
선택 점: 선택이 옮겨 가면 새 점은 나타나고 이전 점은 사라집니다 — 목적지 색 전환과 같은 시간(
duration)과CoreEasing.move곡선의 투명도 전환이고, 동작 줄이기에서는 바로 바뀝니다
사용 가이드라인 (Usage Guidelines)#
선택된 목적지를 표시하는 bar/rail/sidebar에는 NavigationBar를 사용합니다. 상단 트리거가 링크·액션·큰 드롭다운 콘텐츠를 여는 내비게이션은 NavigationMenu, 현재 페이지 안의 탭 선택은 Tabs를 선택합니다. 페이지 끝의 법적 정보·링크 섹션은 Footer로 구성합니다.
화면 제목·뒤로 가기·액션을 담는 헤더는 AppBar로 구성하며 목적지 선택 바와 역할을 구분합니다.
✅ Do#
mode 에 맞는 기본 라벨 동작을 그대로 사용
NavigationBar(
mode: CoreNavigationBarMode.rail,
selectedIndex: index,
onSelected: (i) => setState(() => index = i),
items: destinations,
)
labelMode/labelPosition 을 생략하면 모드별 기본값(bar → all/bottom, rail → all/bottom, sidebar → all/end)이 적용됩니다. 세 모드 모두 라벨 표시 방식을 매번 직접 지정하지 않아도 되는 이유입니다.
❌ Don't#
selectedIndex 를 items 리스트의 원시 인덱스와 혼동하지 않기
// ❌ label/divider 항목까지 포함한 리스트 인덱스를 그대로 사용
NavigationBar(
items: const [
CoreNavigationBarItem.label('Main'), // 선택 불가 — 인덱싱 제외
CoreNavigationBarItem.destination(label: 'Home'), // 실제 selectedIndex 0
CoreNavigationBarItem.divider(), // 선택 불가 — 인덱싱 제외
CoreNavigationBarItem.destination(label: 'Settings'), // 실제 selectedIndex 1
],
selectedIndex: 1, // ❌ 'Home'을 고르려던 의도였다면 어긋남
)
selectedIndex/onSelected 의 인덱스는 선택 가능한 destination 항목만 카운트합니다 — label/divider/gap 은 인덱싱에서 건너뜁니다. items 리스트의 원시 인덱스를 그대로 넘기면 어긋납니다.
접근성 (Accessibility)#
역할#
| Flutter | Web | |
|---|---|---|
| 루트 | Semantics(container: true, label: …) — 랜드마크 role 없음 |
<nav> 요소 + aria-label |
| 목적지 |
목적지 하나가 노드 하나 —
Clickable
이
button
·
enabled
·
selected
를 말하고, 컴포넌트는 이름(
item.label
)만 더하며 배지의 글자를 같은 노드에 합칩니다(
MergeSemantics
)
|
Clickable
이
role="button"
+
tabindex="0"
(활성) /
aria-disabled="true"
(비활성), 컴포넌트가
aria-labelledby
(라벨 요소 → 배지 요소) + 선택 시
aria-current="page"
|
| 구획 라벨 · 구분선 · 여백 항목 | 없음 (Text / Divider / Gap 그대로) |
없음 (<div> / Divider / Gap 그대로) |
선택 표시 방식은 플랫폼마다 다릅니다 — Flutter 는 selected 시맨틱 플래그, Web 은 aria-current="page"
입니다. 둘 다 "지금 여기"를 전달하지만 스크린 리더가 읽는 문구는 같지 않습니다.
Web 루트의 aria-label 은 caller attributes 를 병합한 뒤에 기록되므로 호출부에서 덮을 수 없습니다. 값은 두 플랫폼 모두
navigationLabel 로케일 키에서 옵니다(한국어 기본값 "탐색", 영어 기본값 "Navigation").
키보드#
| 키 | 동작 |
|---|---|
Tab / Shift + Tab | 목적지 하나씩 앞/뒤로 이동 (목적지마다 tab stop) |
Enter | 포커스된 목적지 활성화 |
Space | 포커스된 목적지 활성화 |
Arrow (↑↓←→) |
컴포넌트는 처리하지 않습니다. 네이티브 Flutter 에서는 프레임워크 기본값이 방향 포커스 이동으로 해석하고, Flutter Web 과 Web(Jaspr) 에서는 그렇지 않습니다 — Flutter Web 은 스크롤로, Web 은 아무 동작 없이 흘려보냅니다 |
Home / End | 없음 |
Enter / Space 활성화는 두 플랫폼 모두 합성된 Clickable 이 소유합니다 — Flutter 는
FocusableActionDetector 의 ActivateIntent 단축키로, Web 은 keydown 핸들러로 처리하며 Web 쪽은 이벤트 대상이 자기 자신일 때만 반응해 중첩 컨트롤의 활성화를 가로채지 않습니다.
스크린 리더#
-
이름의 출처는
item.label이고, 배지가 있으면 그 글자가 뒤에 붙습니다(FlutterNotifications\n3, WebNotifications 3). 이름은 한 번만 읽힙니다 — 화면의 라벨 글자는 이름과 겹쳐 읽히지 않습니다.labelMode를.none또는.selected로 두어 라벨이 화면에서 사라져도 이름은 그대로 남습니다 — Flutter 는Semantics(label:), Web 은 시각적으로 숨긴 라벨 요소를aria-labelledby로 가리킵니다. -
비활성 목적지: Flutter
enabled: false, Webaria-disabled="true". 선택된 비활성 목적지도 선택 상태(selected·aria-current)는 알리지 않고 흐린 선택 표시만 그립니다. -
선택 점은 장식입니다 — Flutter 는
ExcludeSemantics, Web 은aria-hidden="true"로 감쌉니다. 선택은 이미 목적지의 선택 상태(Flutterselected, Webaria-current="page")로 읽히므로, 점이 이름에 아무것도 더하지 않습니다. -
라벨이 숨겨진 목적지에는
Tooltip이 함께 합성됩니다. 툴팁 문구가 라벨과 같으면(기본값) 이미 이름이므로 힌트로 다시 읽지 않습니다. 라벨과 다른tooltip을 주면 Flutter 는 그 문구를Semantics(tooltip:)힌트로 노출하지만, Web 은 패널에role="tooltip"만 붙이고 트리거와aria-describedby로 연결하지 않습니다. -
live region 없음 — 선택 인덱스가 바뀌어도 그 변화 자체는 통보되지 않습니다. 목적지를 직접 활성화한 경우에는 포커스가 그 목적지에 있으므로 새 상태가 읽히지만, 외부 코드가
selectedIndex를 바꾸면 조용히 바뀝니다.
포커스 관리#
- 진입 / 이탈: 오버레이가 아니므로 마운트·언마운트 시 포커스를 옮기지 않습니다.
- 트랩: 없습니다.
Tab은 마지막 목적지를 지나 바깥으로 나갑니다. -
tab stop 수: 목적지마다 하나씩 생깁니다(roving tabindex 아님). 목적지가 N 개면 바를 통과하는 데
Tab을 N 번 눌러야 합니다. 구획 라벨 · 구분선 · 여백 항목과 비활성 목적지는 포커스 대상이 아닙니다. -
스크롤 추종: 바는 가로로(
bar), 레일·사이드바는 세로로 스크롤됩니다. 화면 밖 목적지로 포커스가 이동하면 Flutter 는SingleChildScrollView가, Web 은overflow-x-auto/overflow-y-auto의 브라우저 기본 동작이 그 목적지를 시야 안으로 끌어옵니다. -
포커스 표시:
Clickable이 소유합니다 — Flutter 는FocusOutline, Web 은:focus-visible링입니다.
알려진 제약#
-
label을 주지 않은 목적지는 이름이 없습니다.CoreNavigationBarItem.destination의label은 선택 파라미터이고, 두 플랫폼 모두 이름을 그 값(과 배지의 글자)에서만 만듭니다 — 아이콘만 있는 목적지를label없이 만들면 스크린 리더가 "버튼" 이라고만 읽습니다(배지가 있으면 그 숫자만). 대안: 화면에 라벨을 보이고 싶지 않더라도label은 채우고labelMode: .none으로 시각적으로만 감춥니다. -
화살표 키 이동이 없습니다. 표준 내비게이션 패턴은 화살표로 목적지 사이를 옮기고 tab stop 은 하나로 유지하기를 기대하지만, 두 플랫폼 모두 그 배선이 없어 목적지 수만큼 tab stop 이 생깁니다.
onKeyDown은 Web 루트에 노출되어 있으나 목적지별 포커스 상태에 접근할 수 없어 호출부에서 roving 을 대신 구현하기는 어렵습니다. -
Web 에서 토큰 단계가 아닌
duration에는 '동작 줄이기' 설정이 닿지 않습니다. 목적지 색 전환과 선택 점 전환은duration이CoreDuration의 단계(기본 150ms 포함)이면--coui-duration-*변수를 거쳐@media (prefers-reduced-motion: reduce)재정의를 받지만, 단계 밖의 값(예: 175ms)은 리터럴Nms로 나가 그 재정의를 지나칩니다. Flutter 는 어떤 값이든resolveMotionDuration으로 통과시켜 억제합니다. 대안: 단계 위의 값을 씁니다. -
tooltip을label과 다르게 지정하면 그 문구는 Web 에서 통보되지 않습니다. Web 트리거에는 툴팁 패널을 가리키는aria-describedby가 없어 이름(label+ 배지)만 남습니다. Flutter 는Semantics(tooltip:)으로 전달합니다. -
글래스 바에
footer를 주면 움직이는 선택 렌즈가 꺼집니다. 렌즈는 목적지 줄 전체를 따라 움직이는데 아래 칸이 그 줄의 일부를 차지하므로, 두 플랫폼 모두 쉬는 선택 표시만 그립니다. -
Flutter 루트에 내비게이션 랜드마크가 없습니다. Web 은
<nav>로 랜드마크를 만들지만 Flutter 루트는Semantics(container: true, label:)이라 이름만 있고 역할은 없습니다. 스크린 리더의 랜드마크 목록으로 건너뛰는 이동이 Flutter 에서는 되지 않습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | NavigationBar | NavigationBar |
| 렌더 루트 | Container + SingleChildScrollView |
<nav> (flex) |
| 라벨 오버플로 | TextOverflow | CSS text-overflow |
| 배지 (바 · 레일) | Indicator (Stack 위 Position) |
Indicator (절대 위치 오버레이) |
| 아래 칸 배치 | 세로 바의 높이가 고정(tight)이면 Expanded 목적지 + 아래 칸, 높이가 열려 있으면 Flexible(제 크기까지) |
목적지 상자 flex: 1 1 auto + min-*: 0 — 부모가 높이를 정하면 자라고, 아니면 내용만큼 |
| 목적지 이름 | 노드 하나에 이름 + 배지 글자 (줄바꿈으로 이음) | aria-labelledby (공백으로 이음) |
| 아래 안전 영역 (붙은 하단 바) | 아래 패딩에 MediaQuery.paddingOf(context).bottom 을 더함 |
padding-bottom: calc(… + env(safe-area-inset-bottom)) |
| 선택 점 전환 | AnimatedOpacity (duration · CoreEasing.move) |
감싼 요소의
opacity
CSS 전환 (
--coui-duration-*
·
CoreEasing.move
변수)
|
관련 컴포넌트 (Related Components)#
- NavigationBarItem: 바가 목적지마다 합성하는 항목 — 바 밖에서 사이드바를 조립할 때 씁니다
- NavigationMenu: 드롭다운 패널이 있는 가로 메가 메뉴
- Tabs: 화면 내 콘텐츠 섹션 전환에 사용