Dock | CoUI
LogoCoUI

Dock

화면 하단에 고정되는 독 스타일 내비게이션 바

Dock#

모바일/태블릿 앱 하단에 고정되는 아이콘 + 라벨 기반 내비게이션 바입니다. 5단계 사이즈(xs/sm/md/lg/xl)를 지원하며 xs/sm에서는 라벨이 자동으로 숨겨집니다.

Live Preview#

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

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

사이즈 (Sizes)#

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

사용 가이드라인 (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(),
    ),
  ],
)

onPressednull인 아이템은 양 플랫폼 모두 아무 역할도 갖지 않아 탭도 안 되고 스크린 리더에도 안내되지 않습니다.


❌ 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 은 합성된 Clickablerole="button" 이며 활성 아이템에는 aria-current="page" 가 추가됩니다. onPressednull 인 아이템은 양 플랫폼 모두 아무 역할도 갖지 않습니다.

키보드#

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>
backgroundColorColor?String? (CSS)
클릭 커서 MouseRegion(cursor: click) cursor-pointer class
렌더링 Container + Row(spaceAround) <div> flex + 토큰 기반