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

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#

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

속성타입기본값설명
iconW필수아이콘 위젯/컴포넌트
label String? null 아이콘 아래 텍스트 (xs/sm에서는 숨김)
isActiveboolfalse활성 상태 표시
onPressedVoidCallback?null탭 핸들러

사이즈 (Sizes)#

높이는 막대의 최소 높이입니다. 항목이 그 안에 들어가는 동안 막대는 정확히 이 높이이고, 글자가 커져 더 필요하면 막대가 그만큼 자랍니다 (아래 "막대 높이 (Bar Height)" 절 참고).

사이즈최소 높이 (px)아이콘 (px)라벨 표시
xs4820❌
sm5622❌
md (기본)6424✅
lg7228✅
xl8032✅

동작 스펙 (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 selected vs Web aria-current="page". 두 플랫폼에서 리더가 읽는 문구가 같지 않습니다.
  • 아이템이 list / tablist 로 묶여 있지 않아 "3개 중 2번째" 같은 개수·위치가 안내되지 않습니다.
  • 화살표 키 이동과 roving tabindex 가 없어 아이템 수만큼 탭 스톱이 생깁니다.
  • 누를 수 없는 아이템(onPressed == null)은 역할이 없고, 그 isActive 상태는 Flutter 에서만 안내되고 Web 에서는 아무것도 안내되지 않습니다.
  • 라벨이 숨는 사이즈(xs/sm)와 label 이 없는 아이템은 라벨 문자열이 접근성 트리에 들어가지 않습니다. Icon 자체는 이름을 갖지 않으므로 그 아이템은 이름 없는 버튼으로 읽힙니다.

전역으로 적용되는 항목(감소된 모션·고대비·강제 색상·최소 터치 타겟 등)은 전역 접근성 축을 참고하세요.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명DockDock
아이템 타입 CoreDockItemData<Widget> CoreDockItemData<Component>
활성 의미 Semantics(selected:) 눌리는 활성 항목의 aria-current="page"
클릭·포커스합성된 Clickable합성된 Clickable
인디케이터 측정RenderBox 좌표·GlobalKeyDOM rect·요소 id
렌더링 Container + Row(spaceAround) <div> flex + 토큰 기반
막대 높이 Container(constraints: BoxConstraints(minHeight:)) 안의 내용 크기 Stacks 루트 min-height (rem)
인디케이터 여유 항목 행을 감싼 세로 Padding 각 항목(탭 가능하면 그 Clickable 루트)의 세로 margin