NavigationBar | CoUI
본문으로 건너뛰기
LogoCoUI

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#

사용 시기 (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
backgroundColorsurfaceContainerLow

모드 (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: [...])

확장 세로 사이드바 레이아웃입니다. 섹션 라벨과 구분선으로 계층 구조를 표현합니다.

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* 값(바 · 레일 값)을 씁니다. 두 플랫폼 모두 인스턴스 · 테마 값 > 모드 칸 > 평평한 기본값 순서로 읽습니다.

항목barrailsidebar
바 패딩세로 8 · 가로 12 (+ 아래 안전 영역)8세로 16 · 가로 12
바 면 surface surfaceContainerLow surfaceContainerLow
항목 사이082
항목 최소 높이64 (라벨을 숨기면 40)64 (라벨을 숨기면 40)40
항목 패딩세로 6 · 가로 4세로 6 · 가로 4세로 10 · 가로 12
항목 모서리101010
아이콘24 (48×28 상자 안)24 (48×28 상자 안)20
아이콘 ↔ 라벨4410
라벨 역할 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 % + 포커스 링
선택된 목적지의 상태선택 표시 위에 같은 상태 레이어선택 표시 위에 같은 상태 레이어
쉬는 아이콘 · 라벨onSurfaceSubtleonSurfaceSubtle

쉬는 목적지는 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,
    ),
  ],
)

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 을 주면 선택된 탭의 라벨 위에 작은 점이 하나 더 붙습니다.

Web
Flutter
Loading Flutter...
코드 보기
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)#

역할#

FlutterWeb
루트 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 이고, 배지가 있으면 그 글자가 뒤에 붙습니다(Flutter Notifications\n3, Web Notifications 3). 이름은 한 번만 읽힙니다 — 화면의 라벨 글자는 이름과 겹쳐 읽히지 않습니다. labelMode 를 .none 또는 .selected 로 두어 라벨이 화면에서 사라져도 이름은 그대로 남습니다 — Flutter 는 Semantics(label:), Web 은 시각적으로 숨긴 라벨 요소를 aria-labelledby 로 가리킵니다.
  • 비활성 목적지: Flutter enabled: false, Web aria-disabled="true". 선택된 비활성 목적지도 선택 상태(selected · aria-current)는 알리지 않고 흐린 선택 표시만 그립니다.
  • 선택 점은 장식입니다 — Flutter 는 ExcludeSemantics, Web 은 aria-hidden="true" 로 감쌉니다. 선택은 이미 목적지의 선택 상태(Flutter selected, Web aria-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)#

항목FlutterWeb
클래스명NavigationBarNavigationBar
렌더 루트 Container + SingleChildScrollView <nav> (flex)
라벨 오버플로TextOverflowCSS 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 변수)
  • NavigationBarItem: 바가 목적지마다 합성하는 항목 — 바 밖에서 사이드바를 조립할 때 씁니다
  • NavigationMenu: 드롭다운 패널이 있는 가로 메가 메뉴
  • Tabs: 화면 내 콘텐츠 섹션 전환에 사용