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

Stepper

다단계 프로세스를 인터랙티브하게 탐색할 수 있는 스텝 컴포넌트

목차

사용자가 단계를 클릭하여 이동할 수 있는 인터랙티브 프로세스 컴포넌트입니다.

기본 사용법 (Basic Usage)#

Stepper(
  currentStep: 1,
  steps: [
    CoreStepItem(label: 'Account', description: 'Create account'),
    CoreStepItem(label: 'Profile', description: 'Set up profile'),
    CoreStepItem(label: 'Complete', description: 'All done'),
  ],
  onStepChanged: (index) => setState(() => _step = index),
)

Live Preview#

사용 시기 (When to Use)#

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

  • 회원가입, 결제, 설정 마법사 등 다단계 폼
  • 사용자가 이전 단계로 돌아가거나 특정 단계로 점프할 수 있어야 할 때

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

  • Steps: 읽기 전용 진행 상태 표시 (탭 불가)
  • Tabs: 독립적인 콘텐츠 간 전환 (순서/진행 개념 없음)

Props / Parameters#

속성타입기본값설명
stepsList<CoreStepItem>필수단계 목록
currentStep int 0 현재 활성 단계 인덱스 (0-based)
orientation CoreStepperOrientation .horizontal 배치 방향 (horizontal/vertical)
onStepChanged void Function(int)? null 단계 클릭 시 호출
enabled bool? null 활성 여부. null 이면 onStepChanged != null 을 따릅니다 — 콜백이 없는 스테퍼는 비활성 색으로 그려집니다
stepperStyle CoreStepperStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

Stepper 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreStepperStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum (orientation) 과 behaviour (steps / currentStep / onStepChanged) 는 위젯/컴포넌트 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / behaviour: 위젯/컴포넌트 파라미터로 직접 (steps, currentStep, orientation, onStepChanged, enabled)
  • chrome / dimensional / 슬롯 스타일: CoreStepperStyle 한 곳으로 (indicatorActiveColor / indicatorInactiveColor / indicatorInactiveBorderColor / indicatorBorderColor / indicatorBorderWidth / indicatorSize / connectorActiveColor / connectorInactiveColor / connectorThickness / indicatorContentGapStyle / stepPadding / connectorMargin / connectorMarginVertical / labelGapStyle / descriptionGapStyle / connectorMinExtent / iconSizeRatio / labelStyle / labelUpcomingStyle / descriptionStyle / indicatorIconStyle, 비활성: indicatorDisabledColor / indicatorDisabledBorderColor / connectorDisabledColor / disabledForegroundColor)

Resolve chain#

design system default for stepper
  → CoreStepperTheme.style                    // 프로젝트 공통
  → parent component slot override
  → widget.stepperStyle                       // 인스턴스별

각 nested 슬롯 스타일 (labelStyle / descriptionStyle / indicatorIconStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreStepperStyle 필드#

필드타입설명
indicatorActiveColor CoreColor? Indicator (circle) fill colour for the completed and current steps. Falls back to [defaultIndicatorActiveColor].
indicatorInactiveColor CoreColor? Indicator fill colour for upcoming steps. Falls back to [defaultIndicatorInactiveColor].
indicatorBorderColor CoreColor? Indicator border stroke colour — a ring on every step's circle, whatever its state. No default, and must not have one. A completed or current circle has no ring by default, and this field is half of how a caller asks for one: both platforms compute hasBorder = indicatorBorderColor != null || indicatorBorderWidth != null for those circles and draw no stroke when it is false (Flutter leaves BoxDecoration.border null, Web writes no border-* declarations). A constant would make hasBorder permanently true and put a ring on every completed and current indicator on both platforms. It could not hold the right value anyway. Once a caller does opt in without naming a colour, the stroke falls back to the indicator's own fill for that step — indicatorBorderColor ?? color on Flutter, ?? fillCss on Web — which is per-state and therefore not something one static const can name. [indicatorBorderWidth] has a named default because a width is the same number in either state; a colour is not. An upcoming circle always wears a ring, and states it through [indicatorInactiveBorderColor]; a colour stated here wins over that.
indicatorInactiveBorderColor CoreColor? Ring colour of an upcoming step's circle. Falls back to [defaultIndicatorInactiveBorderColor]; [indicatorBorderColor], when stated, wins over it.
indicatorBorderWidth double? Indicator border stroke width (logical px). Falls back to [defaultIndicatorBorderWidth] wherever a ring is drawn.
indicatorSize double? Indicator diameter (logical px).
indicatorBorderRadius CoreBorderRadius? Indicator (circle) corner radius override. Falls back to [defaultIndicatorBorderRadius] (a full circle) when null.
indicatorDisabledColor CoreColor? Indicator fill colour of a completed or current step while the stepper is disabled. Falls back to [defaultIndicatorDisabledColor]. An upcoming circle keeps [indicatorInactiveColor] — its fill is the surface either way.
indicatorDisabledBorderColor CoreColor? Ring colour of an upcoming step's circle while the stepper is disabled. Falls back to [defaultIndicatorDisabledBorderColor].
connectorActiveColor CoreColor? Connector line colour for completed segments. Falls back to [defaultConnectorActiveColor].
connectorInactiveColor CoreColor? Connector line colour for upcoming segments. Falls back to [defaultConnectorInactiveColor].
connectorDisabledColor CoreColor? Connector line colour while the stepper is disabled — every segment. Falls back to [defaultConnectorDisabledColor].
connectorThickness double? Connector line thickness (logical px).
indicatorContentGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between the indicator column and the step content. Forwarded straight to Gap(gapStyle: …) by the platform resolvers. Per-instance override is merged on top of [defaultIndicatorContentGapStyle].
stepPadding CoreEdgeInsets? Padding below each step content in the vertical layout. Falls back to [defaultStepPadding] when null.
connectorMargin double? Margin on each side of a connector in the horizontal layout (logical px). Falls back to [defaultConnectorMargin] when null.
connectorMarginVertical double? Margin above and below a connector in the vertical layout (logical px). Falls back to [defaultConnectorMarginVertical] when null.
labelGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between the indicator and its label in the horizontal layout. Forwarded straight to Gap(gapStyle: …) by the platform resolvers. Per-instance override is merged on top of [defaultLabelGapStyle].
descriptionGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between a step's label and its description. Forwarded straight to Gap(gapStyle: …) by the platform resolvers. Per-instance override is merged on top of [defaultDescriptionGapStyle].
connectorMinExtent double? Minimum extent (length) of the connector in the vertical layout (logical px). Falls back to [defaultConnectorMinExtent] when null.
iconSizeRatio double? Check icon size ratio relative to the indicator diameter. Falls back to [defaultIconSizeRatio] when null.
disabledForegroundColor CoreColor? Ink of the check glyph, the step numbers, the labels and the descriptions while the stepper is disabled. Falls back to [defaultDisabledForegroundColor], and wins over the colour the same slot states for an enabled stepper.
labelStyle CoreTextStyle? Step label text style override for every step, in either orientation. Merged on top of the orientation default and, on the current step, on top of the [labelActiveStyle] overlay too, so a weight stated here reaches every step, the current one included. No default* of its own: the baseline is the orientation default ([defaultLabelVerticalStyle] / [defaultLabelHorizontalStyle]) this overlays, and a default here would sit on top of both and take their roles away.
descriptionStyle CoreTextStyle? Step description text style override.
indicatorIconStyle CoreIconStyle? Indicator icon style (applied when an indicator renders an icon — e.g. checkmark for completed steps).
indicatorNumberActiveStyle CoreTextStyle? Active (current-step) indicator number text style override. null → [defaultIndicatorNumberActiveStyle].
indicatorNumberInactiveStyle CoreTextStyle? Inactive (pending-step) indicator number text style override. null → [defaultIndicatorNumberInactiveStyle].
labelVerticalStyle CoreTextStyle? Step label text style override applied in vertical orientation. null → [defaultLabelVerticalStyle]. Merged with [labelStyle] on top (per-instance label override applies regardless of orientation).
labelHorizontalStyle CoreTextStyle? Step label text style override applied in horizontal orientation. null → [defaultLabelHorizontalStyle]. Merged with [labelStyle] on top.
labelActiveStyle CoreTextStyle? Overlay for the current step's label, in either orientation. Merged on top of [defaultLabelActiveStyle]; the result sits between the orientation default and the per-instance [labelStyle].
labelUpcomingStyle CoreTextStyle? Overlay for an upcoming step's label, in either orientation. Merged on top of [defaultLabelUpcomingStyle]; the result sits between the orientation default and the per-instance [labelStyle]. Only the visible label takes it — the invisible layers that reserve the column's width stay the current / reached styles, so an overlay that changes the weight or size of an upcoming label can outgrow them.
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed per-indicator Clickable (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.

빠른 오버라이드 (Chain)#

이미 만든 Stepper 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

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

  @override
  State<StepperChainExample> createState() => _StepperChainExampleState();
}

class _StepperChainExampleState extends State<StepperChainExample> {
  int _currentStep = 1;

  void handleStepChanged(int index) {
    setState(() {
      _currentStep = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stepper(
      currentStep: _currentStep,
      onStepChanged: handleStepChanged,
      steps: const [
        CoreStepItem(label: 'Account', description: 'Create account'),
        CoreStepItem(label: 'Profile', description: 'Set up profile'),
        CoreStepItem(label: 'Complete', description: 'All done'),
      ],
    ).withStyle(
      const CoreStepperStyle(
        indicatorActiveColor: CoreColor.token(CoreColors.primary),
        indicatorSize: CoreSize.size40,
        connectorThickness: CoreStrokeWidth.stroke6,
        indicatorContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
      ),
    );
  }
}
class StepperChainExample extends StatefulComponent {
  const StepperChainExample({super.key});

  @override
  State<StepperChainExample> createState() => _StepperChainExampleState();
}

class _StepperChainExampleState extends State<StepperChainExample> {
  int _currentStep = 1;

  void handleStepChanged(int index) {
    setState(() {
      _currentStep = index;
    });
  }

  @override
  Component build(BuildContext context) {
    return Stepper(
      currentStep: _currentStep,
      onStepChanged: handleStepChanged,
      steps: const [
        CoreStepItem(label: 'Account', description: 'Create account'),
        CoreStepItem(label: 'Profile', description: 'Set up profile'),
        CoreStepItem(label: 'Complete', description: 'All done'),
      ],
    ).withStyle(
      const CoreStepperStyle(
        indicatorActiveColor: CoreColor.token(CoreColors.primary),
        indicatorSize: CoreSize.size40,
        connectorThickness: CoreStrokeWidth.stroke6,
        indicatorContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
      ),
    );
  }
}

변형 (Variants)#

가로 배치#

Stepper(
  currentStep: 1,
  orientation: CoreStepperOrientation.horizontal,
  steps: [
    CoreStepItem(label: '기본 정보'),
    CoreStepItem(label: '주소 입력'),
    CoreStepItem(label: '결제 수단'),
    CoreStepItem(label: '확인'),
  ],
  onStepChanged: (i) => setState(() => _step = i),
)

세로 배치#

Stepper(
  currentStep: 1,
  orientation: CoreStepperOrientation.vertical,
  steps: [
    CoreStepItem(label: '기본 정보', description: '이름과 이메일'),
    CoreStepItem(label: '주소 입력', description: '배송지 설정'),
    CoreStepItem(label: '결제 수단', description: '카드 등록'),
  ],
  onStepChanged: (i) => setState(() => _step = i),
)

동작 스펙 (Behavior)#

상태 표시#

  • currentStep 미만: 완료 (primary 원 + onPrimary 체크 아이콘, 라벨 onSurface) — 이 단계에서 나가는 연결선은 primary
  • currentStep 인덱스: 현재 (primary 원 + 번호, 세미볼드 라벨) — 나가는 연결선은 outlineVariant
  • currentStep 초과: 대기 (surface 원 + outlineVariant 1px 테두리 + onSurfaceVariant 번호, 라벨도 onSurfaceVariant)

번호는 labelMedium 세미볼드, 가로 라벨은 labelLarge(현재 단계만 세미볼드), 세로 라벨은 titleSmall 세미볼드입니다. 라벨과 설명 사이는 descriptionGapStyle(2), 가로 레이아웃의 연결선 좌우 여백은 connectorMargin(8), 세로 레이아웃의 원 위 · 아래 여백은 connectorMarginVertical(4)입니다.

비활성 상태#

enabled 가 false 이거나(null 이면 onStepChanged == null) 콜백이 없는 스테퍼는 흐려지지 않고 비활성 색으로 그려집니다 — 완료 · 현재 원은 disabledContainer, 연결선과 대기 원의 테두리는 disabledOutline, 체크 · 번호 · 라벨 · 설명은 disabledContent 입니다(disabledForegroundColor 한 값). 대기 원의 채움은 비활성에서도 surface 입니다. 비활성 스테퍼는 Clickable 로 감싸지 않으므로 포커스 · 호버 · 눌림 피드백이 없고, 불투명도도 따로 낮추지 않습니다.

인터랙션#

  • 각 단계 인디케이터 클릭 → onStepChanged(index) 호출
  • 키보드 네비게이션은 표준 Tab 순서 따름

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

단계 클릭으로 onStepChanged를 통보하는 흐름에는 Stepper를 사용합니다. 단계를 보여주기만 하면 Steps, 진행 단계와 무관한 패널 선택은 Tabs를 선택합니다. Stepper가 폼을 검증하거나 다음 단계 진행을 자동 승인하지 않으므로 호출자가 상태와 진입 조건을 관리합니다.

✅ Do#

임의 점프 허용 여부는 콜백 안에서 직접 검증

Stepper(
  currentStep: step,
  steps: steps,
  onStepChanged: (index) {
    if (index <= step) setState(() => step = index); // 완료된 단계로만 되돌아가기 허용
  },
)

onStepChanged가 있으면 완료 여부와 무관하게 모든 인디케이터가 동일하게 클릭 가능해집니다. 아직 도달하지 않은 단계로의 점프를 막으려면 콜백 안에서 인덱스를 직접 검증해야 합니다.


❌ Don't#

읽기 전용으로 쓰려고 no-op 콜백을 넘기지 않기

// ❌ 읽기 전용 진행 표시가 목적인데 onStepChanged를 채워둠
Stepper(
  currentStep: step,
  steps: steps,
  onStepChanged: (_) {},
)

onStepChanged가 null이 아니면 모든 인디케이터가 Clickable(포커스 링·호버·커서 포함)로 감싸집니다. 진짜 읽기 전용 진행 표시가 필요하면 Steps 컴포넌트를 사용하세요. onStepChanged를 생략한 Stepper 는 enabled 가 null 일 때 비활성 색으로 그려지므로, 콜백 없이 활성 색을 쓰려면 enabled: true 를 명시합니다.

접근성 (Accessibility)#

역할#

  • Flutter — Stepper 자신은 Semantics 를 하나도 내보내지 않습니다. onStepChanged 를 넘겼을 때만 인디케이터가 Clickable 로 감싸여 Semantics(button: true, enabled: …) 를 받고(stepper.dart:102, clickable.dart:505), 단계 번호·현재 단계·완료 여부를 알리는 role 이나 상태는 붙지 않습니다.
  • Web — onStepChanged 를 넘겼을 때 인디케이터가 Clickable 이 되어 role="button" + tabindex="0" 에 더해 aria-label="Step N: 라벨" 과, 현재 단계에는 aria-current="step" 이 붙습니다(stepper.dart:295). 내비게이션을 주지 않으면 인디케이터는 role 없는 평범한 <div> 로 남습니다(stepper.dart:290). 루트 <div> 에는 list / group 류 role 이 없습니다(stepper.dart:181).

가로 레이아웃의 폭 예약용 라벨 사본 두 개는 리더에서 제외됩니다 — Flutter 는 ExcludeSemantics(stepper.dart:216, :226), Web 은 aria-hidden="true"(stepper.dart:221, :227) 로 같은 처리를 합니다.

키보드#

키FlutterWeb
Tab / Shift+Tab onStepChanged 가 있을 때 단계마다 개별 tab stop onStepChanged 가 있을 때 단계마다 개별 tab stop
Enter / Space 포커스된 단계로 이동 (onStepChanged(index)) 포커스된 단계로 이동 (onStepChanged(index))
ArrowLeft / ArrowRight 로 단계 간 이동 없음 없음
Home / End 로 첫/마지막 단계 없음 없음

컴포넌트가 바인딩하는 키는 Enter / Space 뿐입니다(clickable.dart:14). onStepChanged 를 넘기지 않으면 키보드로 도달할 수 있는 요소가 하나도 없습니다.

스크린 리더#

  • Web — 인디케이터 버튼의 이름은 CouiLocalizations.stepperStepLabel 에서 옵니다(stepper.dart:298 → "Step 1: Account" / "1단계: Account"). 같은 라벨 텍스트가 바로 옆 노드에도 그대로 남아 있어, 브라우즈 모드에서는 버튼 이름과 라벨을 연달아 두 번 듣게 됩니다(가로 stepper.dart:229, 세로 stepper.dart:152).
  • Flutter — 같은 문자열이 CoUILocalizations.stepperStepLabel 로 이미 존재하지만 위젯이 호출하지 않습니다(coui_localizations.dart:433). 그래서 대기·현재 단계 버튼은 숫자만 읽히고, 완료 단계 버튼은 내용이 체크 아이콘뿐이라 이름이 아예 없습니다(stepper.dart:289). aria-current 에 해당하는 표시도 없습니다.

onStepChanged 가 없는 읽기 전용 스테퍼는 양 플랫폼 모두 번호와 라벨·설명이 평범한 텍스트로만 읽히고, 어느 단계가 현재인지는 굵기와 색으로만 표현됩니다.

진행이 바뀐 사실을 알리는 live region 은 양쪽 다 없습니다.

포커스 관리#

  • 진입 / 이탈 / 트랩 — 모두 없음. 오버레이가 아니라 인라인 진행 표시라 마운트·갱신 때 포커스를 옮기지 않고, Tab 은 스테퍼 밖으로 그대로 빠져나갑니다.
  • 내비게이션이 켜지면 단계 수만큼 tab stop 이 생깁니다 — roving tabindex 를 쓰지 않아 8 단계 스테퍼를 지나가려면 Tab 을 8 번 눌러야 합니다.
  • currentStep 이 바뀌어도 포커스를 옮기지 않습니다. 눌린 인디케이터에 포커스가 그대로 남습니다.

알려진 제약#

  • Flutter 인디케이터에는 이름도 현재 단계 표시도 없습니다 — Web 이 aria-label 과 aria-current="step" 을 붙이는 자리에 대응하는 Semantics 가 없어, 같은 스테퍼가 플랫폼에 따라 다르게 안내됩니다. 대안은 없습니다: 인디케이터는 위젯 내부에서 생성되고, CoreStepItem 은 label / description 만 받으며(stepper_contract.dart:14), Flutter Stepper 에는 attributes / Semantics 슬롯이 없습니다(stepper.dart:39).
  • 완료 구간과 남은 구간의 커넥터가 색으로만 구분됩니다 — 두께는 같은 값을 쓰고 색 토큰만 primary ↔ outlineVariant 로 갈립니다(Flutter stepper_style.dart:245, Web stepper_style.dart:679). OS 의 색 강제 모드에서는 두 색이 같은 시스템 색으로 접히므로 진행 구간이 통째로 사라집니다. 라벨 굵기와 완료 단계의 체크 아이콘은 남으니, 진행률을 커넥터 색에만 의존해 읽히게 두지 마세요.

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

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

항목FlutterWeb
클래스명StepperStepper
렌더링 Column/Row + GestureDetector <div> + 인라인 스타일
아이콘 Icon(LucideIcons.check) Icon(LucideIcons.check)
테마CoreStepperThemeCoreStepperTheme
  • Steps: 읽기 전용 진행 상태 표시
  • Tabs: 독립적인 콘텐츠 전환
  • Progress: 단일 진행률 표시