DotIndicator | CoUI
LogoCoUI

DotIndicator

페이지나 슬라이드 위치를 점으로 표시하는 인디케이터 컴포넌트

DotIndicator#

캐러셀, 온보딩 화면 등에서 현재 페이지 위치를 점으로 표시하는 인디케이터 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 캐러셀이나 PageView에서 현재 페이지 위치를 표시할 때
  • 온보딩 화면에서 전체 단계 중 현재 위치를 안내할 때
  • 슬라이드나 갤러리에서 탐색 위치 표시와 함께 클릭으로 직접 이동을 제공할 때

대신 다른 컴포넌트를 사용하세요:

  • Steps: 완료/진행 중/미완료 상태가 있는 단계별 진행 상황을 표시할 때
  • Progress: 진행률을 퍼센트로 표시할 때
  • Pagination: 페이지 번호 목록 탐색이 필요할 때

기본 사용법 (Basic Usage)#

// 기본 인디케이터
DotIndicator(
  index: currentPage,
  length: 5,
)

// 색상 지정
DotIndicator(
  index: currentPage,
  length: 3,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.primary),
    inactiveColor: CoreColor.token(CoreColors.surfaceContainerHigh),
  ),
)

// 크기 지정 — sm / md(기본) / lg 가 점 지름과 간격을 함께 정합니다
DotIndicator(
  index: currentPage,
  length: 4,
  size: CoreDotIndicatorSize.lg,
)

// PageView와 연동 (점 클릭으로 이동)
PageView(
  onPageChanged: (index) => setState(() => currentPage = index),
  children: pages,
),
DotIndicator(
  index: currentPage,
  length: pages.length,
  onChanged: (newIndex) => unawaited(pageController.animateToPage(
    newIndex,
    duration: const Duration(milliseconds: CoreDuration.moderate),
    curve: Curves.easeInOut,
  )),
)
// 기본 인디케이터
DotIndicator(
  index: currentPage,
  length: 5,
)

// 색상 지정
DotIndicator(
  index: currentPage,
  length: 3,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.primary),
    inactiveColor: CoreColor.token(CoreColors.surfaceContainerHigh),
  ),
)

// 크기 지정 — sm / md(기본) / lg 가 점 지름과 간격을 함께 정합니다
DotIndicator(
  index: currentPage,
  length: 4,
  size: CoreDotIndicatorSize.lg,
)

// 세로 방향 + 페이지 연동 (onChanged 콜백)
DotIndicator(
  index: currentPage,
  length: pages.length,
  direction: CoreAxis.vertical,
  onChanged: handlePageChange,
)

Props / Parameters#

속성타입기본값설명
indexint필수현재 활성 점 인덱스 (0-based)
lengthint필수총 점 개수
onChanged CoreDotIndicatorOnChanged? null 점 클릭 시 페이지 이동 핸들러
direction CoreAxis CoreAxis.horizontal 레이아웃 방향 (Flutter / Web 동일)
size CoreDotIndicatorSize CoreDotIndicatorSize.md 점 지름 + 점 간격 ( sm 6/6 · md 8/8 · lg 12/12 px). widget-only 시맨틱 식별자 — 테마가 override 하지 않음
dotIndicatorStyle CoreDotIndicatorStyle? null chrome / 치수 단일 진입점 (아래 표 참고)

스타일 시스템 (Style System)#

모든 chrome / 치수 override 는 dotIndicatorStyle 슬롯 하나로 흐릅니다.

CoreDotIndicatorStyle 필드#

필드타입설명
spacing double? Spacing between individual dots (logical px). null defers to [defaultsBySize] for the active CoreDotIndicatorSize .
padding CoreEdgeInsets? Uniform padding around the dot indicator container.
dotSize double? Dot size override (logical px). null defers to [defaultsBySize] for the active CoreDotIndicatorSize .
borderRadius CoreBorderRadius? Dot border radius override.
activeColor CoreColor? Active dot colour override.
inactiveColor CoreColor? Inactive dot fill override. Defaults to [defaultInactiveColor] — a solid outlineVariant disc. CoreColor.transparent restores a ring (fill absent, [inactiveBorderColor] alone).
inactiveBorderColor CoreColor? Inactive dot border colour override.
inactiveBorderWidth double? Inactive dot border width override (logical px).
hoverBorderColor CoreColor? Hover border colour override for clickable inactive dots. null defers to [defaultHoverBorderColor]. Only reaches the screen when a border width is stated — see [defaultInactiveBorderWidth].
transitionDuration Duration? Transition duration override for active / hover state changes. null defers to [defaultTransitionDuration].
focusOutlineStyle CoreFocusOutlineStyle? Focus ring chrome drawn around the selected dot while the indicator group holds keyboard focus (roving radio-group model — the group is one tab stop and arrow keys move the selection). Raw-forwarded so the focus-outline layer fills its own defaults.

크기별 기본값 (CoreDotIndicatorStyle.defaultsBySize)#

size 가 고르는 rung 입니다. dotSize / spacing 을 슬롯으로 주면 그 값이 rung 을 덮습니다.

sizedotSizespacing
sm CoreSpace.space6 (6px) CoreSpace.space6 (6px)
md (기본) CoreSpace.space8 (8px) CoreSpace.space8 (8px)
lg CoreSpace.space12 (12px) CoreSpace.space12 (12px)

Resolve chain#

CoreDotIndicatorStyle.defaultX / defaultsBySize[size] (static const, 단일 출처)
  → CoreDotIndicatorTheme.style                   // 프로젝트 공통
  → widget.dotIndicatorStyle                      // 인스턴스별

빠른 오버라이드 (Chain)#

이미 만든 DotIndicator 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class DotIndicatorChainExample extends StatefulWidget {
  const DotIndicatorChainExample({super.key});

  @override
  State<DotIndicatorChainExample> createState() => _DotIndicatorChainExampleState();
}

class _DotIndicatorChainExampleState extends State<DotIndicatorChainExample> {
  int _index = 2;

  @override
  Widget build(BuildContext context) {
    return DotIndicator(
          index: _index,
          length: 5,
          onChanged: (v) => setState(() => _index = v),
        )
        .withStyle(
          const CoreDotIndicatorStyle(
            activeColor: CoreColor.token(CoreColors.tertiary),
            dotSize: CoreSpace.space16,
            spacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}
class DotIndicatorChainExample extends StatefulComponent {
  const DotIndicatorChainExample({super.key});

  @override
  State<DotIndicatorChainExample> createState() => _DotIndicatorChainExampleState();
}

class _DotIndicatorChainExampleState extends State<DotIndicatorChainExample> {
  int _index = 2;

  @override
  Component build(BuildContext context) {
    return DotIndicator(
          index: _index,
          length: 5,
          onChanged: (v) => setState(() => _index = v),
        )
        .withStyle(
          const CoreDotIndicatorStyle(
            activeColor: CoreColor.token(CoreColors.tertiary),
            dotSize: CoreSpace.space16,
            spacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}

변형 (Variants)#

기본형 (가로)#

동일한 크기의 점으로 활성/비활성을 색상으로 구분합니다.

DotIndicator(index: 0, length: 4)

세로형#

direction 으로 축을 바꿉니다 — 화살표 키도 ↑/↓ 로 따라갑니다.

DotIndicator(
  index: 0,
  length: 4,
  direction: CoreAxis.vertical,
)

클릭 가능형#

onChanged 를 주면 점이 클릭 대상이 되고 키보드 조작이 켜집니다.

DotIndicator(
  index: currentPage,
  length: 4,
  onChanged: (i) => setState(() => currentPage = i),
)

동작 스펙 (Behavior)#

인터랙션#

  • 점 클릭: onChanged 가 있을 때만 활성화되며 클릭한 인덱스를 콜백으로 전달
  • hover: 클릭 가능한 비활성 점이 살짝 확대됩니다. 기본 점은 테두리 폭이 0 이므로 hoverBorderColor 는 폭을 직접 진술한 호출자에게만 닿습니다
  • index 변경: 외부에서 상태를 바꾸면 활성 점이 갱신됩니다 (컴포넌트는 자체 상태를 갖지 않음)

애니메이션#

  • 색상 / hover scale 전환: dotIndicatorStyle.transitionDuration (기본 200ms)
  • Flutter: AnimatedContainer + AnimatedScale, Web: CSS transition

사용 가이드라인 (Usage Guidelines)#

✅ Do#

PageView와 함께 연동

Column(
  children: [
    Expanded(
      child: PageView(
        onPageChanged: (index) => setState(() => _currentPage = index),
        children: onboardingPages,
      ),
    ),
    Padding(
      padding: const EdgeInsets.only(bottom: CoreSpace.space24),
      child: DotIndicator(
        index: _currentPage,
        length: onboardingPages.length,
        onChanged: (index) => unawaited(pageController.animateToPage(
          index,
          duration: const Duration(milliseconds: CoreDuration.moderate),
          curve: Curves.easeInOut,
        )),
      ),
    ),
  ],
)

PageView와 연동하여 현재 페이지를 실시간으로 반영하고 점 클릭으로 직접 이동할 수 있게 합니다.


❌ Don't#

많은 점을 나열하지 않기

// ❌ 너무 많은 점
DotIndicator(
  index: currentPage,
  length: 15, // 너무 많음
)

점이 너무 많으면 현재 위치 파악이 어렵고 터치 타겟이 작아집니다. 5개 이하를 권장하며, 그 이상은 Pagination 사용을 고려하세요.

✅ Do#

활성 상태를 색상만으로 구분하지 않기

DotIndicator(
  index: currentPage,
  length: 4,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    activeColor: CoreColor.token(CoreColors.onSurface),
    // 비활성을 채움 없이 테두리만으로 — 색 강제 모드는 채움도 테두리도
    // 시스템 색으로 접지만 형태는 그대로 두므로, 원과 링의 차이는 남습니다.
    // 기본은 둘 다 채워진 원이라 그 모드에서 활성/비활성이 같아 보입니다
    inactiveColor: CoreColor.transparent,
    inactiveBorderColor: CoreColor.token(CoreColors.outlineVariant),
    inactiveBorderWidth: CoreStrokeWidth.stroke2,
  ),
)

채움 유무까지 다르면 색을 구별하기 어려운 사용자도 활성 상태를 인식할 수 있습니다.


❌ Don't#

클릭 가능한 점을 너무 작게 두지 않기

// ❌ 클릭 가능한데 점이 4px
DotIndicator(
  index: currentPage,
  length: 4,
  onChanged: handleDotTap,
  dotIndicatorStyle: CoreDotIndicatorStyle(
    dotSize: CoreSpace.space4, // 너무 작음
    padding: CoreEdgeInsets.zero, // 히트 영역까지 함께 줄어듦
  ),
)

onChanged 가 있으면 각 점이 탭 타겟입니다. 히트 영역은 dotSizepadding / spacing 이 함께 만들므로, 기본값(md: 8px 점 + 8px 간격 + 8px 여백)보다 줄일 때는 최소 터치 타겟(→ 전역 접근성 축)을 확인하세요.

접근성 (Accessibility)#

키보드·시맨틱은 onChanged 가 있을 때만 켜집니다. onChanged 가 없으면 점들은 장식이고 포커스도 받지 않습니다.

키보드 인터랙션#

인디케이터 전체가 하나의 탭 정지이고, 화살표 키가 선택과 포커스 링을 함께 옮깁니다 (라디오 그룹 모델 — 개별 점은 탭 순서에서 빠집니다).

동작
/ (가로)다음/이전 점 선택 (onChanged 호출)
/ (세로)다음/이전 점 선택
Tab인디케이터 밖 다음 요소로 이동

양 끝에서는 clamp 되어 순환하지 않습니다. Home / End 는 없습니다.

스크린 리더#

  • Web: 컨테이너에 role="tablist" + aria-label, 각 점에 role="tab" + aria-label(항목 번호), 활성 점에 aria-current="true" 를 emit 합니다. 라벨은 CouiLocalizations 에서 옵니다.
  • Flutter: 컨테이너를 Semantics(container: true, label: ...dotIndicatorLabel) 로 감싸고, 각 점을 Semantics(label: ...dotIndicatorGoToItem(i + 1), button: interactive, selected: i == index) 로 감쌉니다 — Web 의 tablist/tab 구조와 같은 두 로컬라이제이션 멤버를 같은 값으로 읽으므로, 스크린 리더가 전달하는 이름·선택 상태는 양 플랫폼이 동일합니다.

포커스#

포커스 링은 선택된 점 주위에만 원형으로 그려지며(focusOutlineStyle 로 조정), 키보드 포커스일 때만 나타납니다.

알려진 제약#

  • 점이 나타내는 대상(어떤 슬라이드인지)은 번호로만 전달됩니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.

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

항목FlutterWeb
클래스명DotIndicatorDotIndicator
애니메이션 AnimatedContainer + AnimatedScale CSS transition
키보드 FocusableActionDetector + 화살표 shortcut Clickable + 화살표 키 핸들러
시맨틱 없음 role="tablist" / role="tab" / aria-current
  • Carousel: 내장 인디케이터가 있는 캐러셀 컴포넌트 (DotIndicator 통합)
  • Steps: 단계 완료 상태가 있는 진행 표시가 필요할 때
  • Progress: 퍼센트 기반 진행률 표시에 사용

조합 예제#

// DotIndicator + Carousel 외부 연동
Stacks(
  alignment: CoreAlignment.bottomCenter,
  children: [
    Carousel(
      items: items,
      transition: const SlidingCarouselTransition(),
      showIndicators: false,
      currentIndex: currentPage,
      onIndexChanged: (index) => setState(() => currentPage = index),
    ),
    Position(
      bottom: CoreSpace.space16,
      child: DotIndicator(
        index: currentPage,
        length: items.length,
        onChanged: (index) => setState(() => currentPage = index),
      ),
    ),
  ],
)