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

Toggle

토글 스위치 컴포넌트

목차

켜기/끄기 상태를 전환하는 토글 스위치 컴포넌트입니다.

기본 사용법 (Basic Usage)#

// 기본 토글
Toggle(
  value: isDarkMode,
  onChanged: handleDarkModeToggle,
)

// 라벨 텍스트
Toggle(
  value: isNotificationEnabled,
  onChanged: handleNotificationToggle,
  label: '알림 수신',
)

// 앞에 라벨 Widget 배치
Toggle(
  value: isActive,
  onChanged: handleToggle,
  prefix: Text('자동 저장'),
)

// 뒤에 라벨 Widget 배치
Toggle(
  value: isActive,
  onChanged: handleToggle,
  suffix: Text('자동 저장'),
)

// 비활성화
Toggle(
  value: false,
  onChanged: null,
  enabled: false,
  label: '비활성 옵션',
)
// 기본 토글
Toggle(
  value: isDarkMode,
  onChanged: handleDarkModeToggle,
)

// 라벨 텍스트
Toggle(
  value: isNotificationEnabled,
  onChanged: handleNotificationToggle,
  label: '알림 수신',
)

// 비활성화
Toggle(
  value: false,
  enabled: false,
)

// 초기 상태 on
Toggle(
  value: true,
  onChanged: handleToggle,
)

Live Preview#

구조 (Anatomy)#

코드가 정본입니다. 부분 이름은 위젯 슬롯과 같고, Figma ❖ Toggle 레이어와도 맞습니다.

부분역할슬롯
prefix토글 앞prefix
suffix토글 뒤suffix

사용 시기 (When to Use)#

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

  • 즉시 적용되는 on/off 설정을 전환할 때 (다크 모드, 알림 등)
  • 두 가지 상태만 있고 변경이 바로 반영될 때
  • 설정 목록에서 각 옵션을 개별적으로 토글할 때

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

  • Checkbox: 폼 제출 후에 반영되는 선택일 때
  • Select: 두 가지 이상의 옵션 중 선택할 때
  • Button: 단순 액션 트리거일 때 (상태 보존 불필요)

Props / Parameters#

W는 Flutter의 Widget, Web의 Component를 뜻하는 prefix/suffix 슬롯 타입입니다. 콜백 typedef는 두 플랫폼 모두 void Function(bool) 의미입니다.

속성타입기본값설명
value bool 명시 (Flutter 필수) 현재 상태. 양쪽 호출에서 명시적으로 전달
onChanged ValueChanged<bool>? / CoreValueChanged<bool>? null (Flutter 는 명시 전달 필수) 상태 변경 콜백 — null 이면 비활성
labelString?null라벨 텍스트
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
variant CoreToggleVariant CoreToggleVariant.defaultVariant 시맨틱 변형 (위젯 파라미터)
size CoreComponentSize CoreComponentSize.md 크기 토큰 (위젯 파라미터). 사다리는 sm ~ xl2 — xs 는 행이 없어 sm 으로 그려지며, 예고 중입니다(표 아래 안내)
enabled bool? null → onChanged != null 로 유도 활성화 여부
prefixW?null토글 앞 콘텐츠
suffixW?null토글 뒤 콘텐츠
toggleStyle CoreToggleStyle? null track + thumb chrome / nested slot 묶음
nameString?null폼 제출 이름

xs 는 지금도 sm 으로 그려집니다 — 대신 sm 을 쓰세요. 화면은 바뀌지 않습니다. 지금은 assert 가 켜진 실행(디버그 빌드, Jaspr 정적 빌드의 사전 렌더)에서 로그 경고가 한 번 나오고 — 경고일 뿐 화면이나 빌드를 멈추지 않습니다 — 이르면 coui_core 0.153 부터는 assert 가 켜진 실행이 xs 에서 멈춥니다. assert 를 빼는 빌드는 계속 sm 으로 그립니다.

Flutter 전용 런타임 인프라 (Web 은 브라우저 native focus / DOM 이 같은 capability 를 제공):

속성타입기본값설명
controller ToggleController? null (Flutter 전용) 외부 ValueNotifier<bool> 기반 상태 컨트롤러 (toggle() 제공)
focusNode FocusNode? null (Flutter 전용) 외부 포커스 노드
statesController WidgetStatesController? null (Flutter 전용) 외부 위젯 상태 컨트롤러

빠른 오버라이드 (Chain)#

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

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

  @override
  State<ToggleChainExample> createState() => _ToggleChainExampleState();
}

class _ToggleChainExampleState extends State<ToggleChainExample> {
  bool _value = false;

  @override
  Widget build(BuildContext context) {
    return Toggle(
      value: _value,
      onChanged: (v) => setState(() => _value = v),
      label: 'Toggle',
    ).withStyle(
      const CoreToggleStyle(
        trackActiveColor: CoreColor.token(CoreColors.success),
        trackInactiveColor: CoreColor.token(CoreColors.outlineVariant),
        trackBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
        thumbBorderRadius: CoreBorderRadius.all(CoreRadius.radius2),
        thumbShadow: CoreShadow.sm,
        iconLabelGapStyle: CoreGapStyle(size: CoreSpace.space12),
      ),
    );
  }
}
class ToggleChainExample extends StatefulComponent {
  const ToggleChainExample({super.key});

  @override
  State<ToggleChainExample> createState() => _ToggleChainExampleState();
}

class _ToggleChainExampleState extends State<ToggleChainExample> {
  bool _value = false;

  @override
  Component build(BuildContext context) {
    return Toggle(
      value: _value,
      onChanged: (v) => setState(() => _value = v),
      label: 'Toggle',
    ).withStyle(
      const CoreToggleStyle(
        trackActiveColor: CoreColor.token(CoreColors.success),
        trackInactiveColor: CoreColor.token(CoreColors.outlineVariant),
        trackBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
        thumbBorderRadius: CoreBorderRadius.all(CoreRadius.radius2),
        thumbShadow: CoreShadow.sm,
        iconLabelGapStyle: CoreGapStyle(size: CoreSpace.space12),
      ),
    );
  }
}

스타일 시스템 (Style System)#

toggleStyle 슬롯#

Toggle 의 track + thumb chrome / 슬롯 미세 조정은 단일 toggleStyle (CoreToggleStyle) 으로 흐릅니다. 시맨틱 enum (variant, size) 은 위젯 파라미터.

Toggle(
  variant: CoreToggleVariant.defaultVariant,
  size: CoreComponentSize.md,
  value: enabled,
  onChanged: handleChange,
  label: '알림 받기',
  toggleStyle: CoreToggleStyle(
    trackActiveColor: CoreColor.token(CoreColors.primary),
    trackInactiveColor: CoreColor.token(CoreColors.outline),
    thumbActiveColor: CoreColor.token(CoreColors.onPrimary),
    thumbInactiveColor: CoreColor.token(CoreColors.onSurface),
    trackBorderRadius: CoreBorderRadius.all(CoreRadius.radius12),
    iconLabelGapStyle: CoreGapStyle(size: CoreSpace.space8),
    labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
  ),
)

CoreToggleStyle 필드#

필드타입설명
thumbShadow List<CoreShadowLayer>? Shadow layers on the moving thumb, independent of the track and lens.
thumbShadowBaseColor CoreColor? Base tint of [thumbShadow]; layer opacity and dark strength are retained.
trackActiveColor CoreColor? Track fill colour when on.
trackInactiveColor CoreColor? Track fill colour when off.
trackBorderRadius CoreBorderRadius? Track border radius override.
trackWidth double? Track width override (logical px).
trackHeight double? Track height override (logical px).
thumbActiveColor CoreColor? Thumb fill colour when on.
thumbInactiveColor CoreColor? Thumb fill colour when off.
thumbSize double? Thumb size (width = height) override (logical px).
thumbBorderRadius CoreBorderRadius? Thumb border radius override. Carries no defaultThumbBorderRadius , and the two platforms key different things on its absence — a constant would break both. Flutter tests resolved.thumbBorderRadius == null to choose BoxShape.circle ; a BoxDecoration cannot hold a circle shape and a radius at once, so any non-null default permanently moves the thumb onto the BoxShape.rectangle branch. Web falls back to the resolved trackBorderRadius , so an unset thumb radius follows the track — including a per-instance trackBorderRadius override. A constant here would freeze the thumb and silently unpair it from the track.
thumbOffset double? Thumb travel offset when on (logical px). null falls back to [defaultsBySize] for the widget's size.
inactiveThumbOffset double? Thumb resting (off-state) X translation (logical px). null falls back to [defaultInactiveThumbOffset] (0 — flush with the track's leading edge).
padding CoreEdgeInsets? Inner padding inside the track. null falls back to [defaultsBySize] for the widget's size.
iconLabelGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the gap between the toggle track and the prefix / suffix / label slots — forwarded straight to the Gap widget that separates the slots. null defers to the per-size default in [defaultsBySize], else [defaultIconLabelGapStyle].
colorTransitionDuration Duration? Track colour transition duration. null falls back to [defaultColorTransitionDuration].
thumbTransitionDuration Duration? Thumb travel transition duration. null falls back to [defaultThumbTransitionDuration].
stateLayer CoreStateLayer? How strongly the track's own ink is laid over its fill while hovered / pressed. null falls back to [defaultStateLayer].
stateLayerActiveColor CoreColor? The ink the state layer paints over the on track. null falls back to the variant's activeStateLayer .
stateLayerInactiveColor CoreColor? The ink the state layer paints over the off track. null falls back to the variant's inactiveStateLayer .
focusOutlineStyle CoreFocusOutlineStyle? Nested [CoreFocusOutlineStyle] slot for the focus outline drawn around the track. Merged on top of [defaultFocusOutlineStyle] and raw-forwarded to FocusOutline .
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the composed Clickable (press scale / durations / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded.
labelStyle CoreTextStyle? Label text style override — merged on top of [defaultLabelStyle] and the size row's role.
lensOutset CoreEdgeInsets? How far the transient lens reaches past the thumb on each side. null falls back to [defaultLensOutset]. The lens is a clear capsule that appears over the thumb for exactly the thumb's travel ([thumbTransitionDuration]) and is absent at rest, so this never changes the switch's resting size, layout or pixels. What the capsule shows — blur, refraction, sheen — is the material the active surface style gives a floating surface; under a style that gives none, the lens draws nothing at all. Read as authored under every density, a stated value included — see [defaultLensOutset] for why this inset is geometry, not spacing.
lensContainerStyle CoreOutlinedContainerStyle? Nested [CoreOutlinedContainerStyle] slot for the transient lens's chrome — merged on top of [defaultLensContainerStyle] and raw-forwarded to the lens (Flutter composes OutlinedContainer with it; Web writes its fields inline on the lens element). The slot's width / height are not read: the lens's size is the thumb grown by [lensOutset], and both resolvers lay that size over the slot.
lensFadeFraction double? Share of the thumb's travel the lens spends fading in, and again fading out. null falls back to [defaultLensFadeFraction]; a value below 0 or above [maxLensFadeFraction] is clamped to that bound. 0 shows the lens at full strength for the whole travel; [maxLensFadeFraction] fades it straight in and straight out with no hold.

CoreToggleStyle 변형별 기본값 (CoreToggleVariantStyle)#

필드defaultVariant
activeTrackprimary
inactiveTracksurfaceContainerHighest
activeThumbonPrimary
inactiveThumbonSurface
disabledActiveTrackdisabledContent
disabledInactiveTrackdisabledContainer
disabledThumbsurface
disabledInactiveThumbdisabledContent
disabledForegroundColordisabledContent
activeStateLayersurface
inactiveStateLayeronSurface

variant 별 색은 CoreToggleVariantStyle (activeTrack / inactiveTrack / activeThumb / inactiveThumb / disabledActiveTrack / disabledInactiveTrack / disabledThumb / disabledInactiveThumb / disabledForegroundColor / activeStateLayer / inactiveStateLayer) 로 CoreToggleStyle.defaultsByVariant 에서, 크기·라벨 role 은 CoreToggleStyle.defaultsBySize 에서 결정됩니다. hover / pressed 는 트랙 위에 자기 ink 를 stateLayer 램프(8% / 16%)로 덮은 wash 이고, 키보드 포커스는 트랙만 감싸는 focusOutlineStyle 링입니다.

Resolve chain#

design system default
  → CoreToggleTheme.style                      // 프로젝트 공통
  → parent component slot override
  → widget.toggleStyle                         // 인스턴스별

nested labelStyle 은 자기 컴포넌트의 resolve chain 으로 다시 한 번 머지됩니다.

변형 (Variants)#

크기#

Toggle(value: v1, onChanged: handle, size: CoreComponentSize.sm)
Toggle(value: v2, onChanged: handle, size: CoreComponentSize.md)
Toggle(value: v3, onChanged: handle, size: CoreComponentSize.lg)

설정 목록에서 사용#

Column(
  children: [
    Toggle(
      value: settings.darkMode,
      onChanged: handleDarkMode,
      label: '다크 모드',
    ),
    Toggle(
      value: settings.notifications,
      onChanged: handleNotifications,
      label: '푸시 알림',
    ),
    Toggle(
      value: settings.autoSave,
      onChanged: handleAutoSave,
      label: '자동 저장',
    ),
  ],
)

동작 스펙 (Behavior)#

상태 전환#

  • 클릭/탭 시 value가 true ↔ false 토글
  • enabled: false(또는 onChanged: null)면 인터랙션 비활성 + disabled 색 토큰 적용

상태 관리#

// 직접 콜백 방식
Toggle(
  value: isDarkMode,
  onChanged: (value) => setState(() => isDarkMode = value),
)

상태 레이어 (hover / pressed)#

  • 포인터가 올라오면 트랙 위에 트랙 자신의 ink(activeStateLayer = surface / inactiveStateLayer = onSurface)를 stateLayer.hover(8%) 로, 누르는 동안 stateLayer.pressed(16%) 로 덮습니다. 램프는 CoreStateLayer.standard.
  • 누르는 동안의 피드백은 트랙의 16% wash 와 Clickable 의 눌림 축소(기본값) 둘 다입니다 — 축소는 제스처 동안만 존재하는 모션이라 Figma 캔버스의 pressed 포즈에는 wash 만 그려집니다.
  • Flutter: Clickable 이 hover / press 를 보고하고 위젯이 미리 합성된 OutlinedContainer 번들 중 하나를 고릅니다.
  • Web: .scope:hover .co-toggle-track / :active 스코프 규칙이 color-mix() 로 같은 wash 를 그립니다.

애니메이션#

  • Flutter: 100ms, Curves.easeInOut
    • 트랙 색상: OutlinedContainer로 활성/비활성 색상 전환
    • 썸네일 이동: AnimatedPositioned로 좌우 슬라이딩
  • Web: CSS transition-transform으로 썸네일 위치 전환

전환 렌즈#

사용자가 스위치를 직접 켜고 끌 때(탭 · 클릭 · 키보드 Space/Enter) 썸이 움직이는 동안에만 썸보다 큰 투명 캡슐(렌즈)이 썸을 따라 이동합니다. 앱이 코드로 값을 바꿀 때(폼 리셋 · '모두 켜기' 처럼) 썸은 그대로 움직이지만 렌즈는 뜨지 않습니다. 정지 상태에는 렌즈가 트리·DOM 에 아예 없으므로, 멈춰 있는 스위치의 크기·레이아웃·픽셀은 렌즈가 없을 때와 같습니다.

  • 모양: 썸 상자를 lensOutset 만큼 키운 알약. 레이아웃을 차지하지 않아 트랙 밖으로 넘칠 수 있습니다.
  • 크롬: lensContainerStyle 슬롯(CoreOutlinedContainerStyle) — 기본은 채움 · 테두리 · 그림자가 없는 알약(defaultLensContainerStyle)입니다. 크기는 슬롯이 아니라 lensOutset 이 정합니다.
  • 재질: 렌즈는 떠 있는 표면(surface:functional)이고 기본 크롬에 채움이 없습니다. 보이는 것 (블러·굴절·가장자리 띠)은 전부 활성 서피스 스타일이 떠 있는 표면에 주는 재질이며, 재질이 없는 스타일에서는 아무것도 그려지지 않습니다. 카드처럼 유리 표면 안에 놓인 스위치에서는 블러·굴절이 빠지고 가장자리 띠만 보입니다.
  • 시간: 썸 이동과 같은 thumbTransitionDuration 동안 살고, 처음 lensFadeFraction(기본 1/4)에 나타나 마지막 같은 몫에 사라집니다. 전환 tier 이므로 동작 줄이기에서는 뜨지 않습니다.
  • 비활성: 비활성 토글은 재질 표면으로 읽히지 않으므로 렌즈가 없습니다.
  • 캔버스: Figma Toggle 세트에는 전환 중 자세가 없습니다 — 전환은 프리뷰와 실기기에서 확인합니다.

크기 토큰#

트랙은 5단계 사다리(높이 16/20/24/28/32)의 pill 이고, 너비는 높이 + 16 입니다 (32/36/40/44/48). thumb 은 높이 − 4(안쪽 여백 space/2 양쪽) — On/Off 에서 같은 크기이고, 움직이는 거리는 모든 크기에서 16 입니다. 라벨은 크기마다 역할을 고르고(bodySmall / bodyMedium / bodyLarge / bodyLarge / bodyLarge — 13 / 14 / 15 / 15 / 15), 굵기 400 · 행간 150% 는 역할의 것입니다. xs 행은 없습니다 — 24×12 트랙의 8px 이동은 피드백으로 읽히지 않아 사다리에서 제거됐고, CoreComponentSize.xs 를 넘기면 sm 으로 그려집니다. 이 xs 는 예고 중이라 이르면 coui_core 0.153 부터 assert 가 켜진 실행이 멈춥니다 — 위 속성 표 아래 안내를 보세요.

Size Track (W×H) Thumb 이동 gap 라벨 Flutter Web
sm 32×16 12 16 6 13 CoreToggleStyle.defaultsBySize inline rem
md 36×20 16 16 8 14 CoreToggleStyle.defaultsBySize inline rem
lg 40×24 20 16 8 15 CoreToggleStyle.defaultsBySize inline rem
xl 44×28 24 16 10 15 CoreToggleStyle.defaultsBySize inline rem
xl2 48×32 28 16 10 15 CoreToggleStyle.defaultsBySize inline rem

라벨이 달린 Toggle 의 행 높이는 라벨 줄 상자(라벨 역할의 행간 150%)와 트랙 중 큰 쪽입니다. md 는 라벨 줄 상자 14 × 1.5 = 21 이 트랙 20 보다 커서 행이 21 이고(sm 도 13 × 1.5 = 19.5 가 트랙 16 보다 큽니다), lg 부터는 라벨 줄 상자(15 × 1.5 = 22.5)가 트랙 24 보다 작아 트랙이 이기므로 행 높이가 트랙 높이와 같습니다.

크기를 적지 않은 Toggle(기본 md)은 너비만 40 → 36 으로 좁아지고 높이는 그대로입니다. 이전 너비가 필요한 앱은 toggleStyle: CoreToggleStyle(trackWidth: 40, thumbOffset: 20) 으로 되돌릴 수 있습니다.

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

하나의 bool 값을 트랙과 thumb으로 표현하려면 Toggle을 사용합니다. 라벨·설명을 포함한 전체 설정 행은 SwitchField, 두 아이콘/콘텐츠 교체는 Swap, indeterminate가 필요한 독립 선택은 Checkbox를 선택합니다. enabled를 생략하면 콜백 존재로 활성 상태를 유도하고 값 변경은 호출자가 반영합니다.

✅ Do#

토글에 항상 라벨을 제공하세요.

Toggle(
  value: isDarkMode,
  onChanged: handleDarkMode,
  label: '다크 모드',
)

토글의 목적이 명확해집니다.


❌ Don't#

라벨 없이 토글만 배치하지 마세요.

Toggle(
  value: isDarkMode,
  onChanged: handleDarkMode,
)

사용자가 무엇을 켜고 끄는지 알 수 없습니다.

✅ Do#

즉시 적용되는 설정에 토글을 사용하세요.

Toggle(
  value: notifications,
  onChanged: (value) {
    updateNotificationSetting(value); // 즉시 서버 반영
  },
  label: '푸시 알림',
)

토글 변경 = 즉시 적용이 사용자의 기대입니다.


❌ Don't#

"저장" 버튼이 필요한 설정에 토글을 사용하지 마세요.

저장 전까지 실제 반영이 안 되면 사용자가 혼란스러워합니다. 이 경우 Checkbox가 더 적합합니다.

✅ Do#

긍정형 라벨로 on 상태를 명확히 하세요.

Toggle(label: '알림 받기', ...)
Toggle(label: '자동 저장', ...)

on = 활성화가 직관적입니다.


❌ Don't#

부정형 라벨을 사용하지 마세요.

Toggle(label: '알림 끄기', ...)

켜면 "알림 끄기"가 활성화되어 이중 부정이 됩니다.

접근성 (Accessibility)#

역할#

  • Flutter — Semantics(toggled: value) 로 켜짐/꺼짐을 내보냅니다. 합성된 Clickable 에 semanticsButton: false 를 넘겨 일반 "버튼" 역할은 꺼져 있고, Clickable 이 붙이는 Semantics(enabled:) 는 그대로 남습니다.
  • Web — 인터랙션 루트에 role="switch" · aria-checked="true" | "false" · tabindex 가 붙고, 비활성이면 aria-disabled="true" 가 더해집니다. 트랙과 thumb 은 aria-hidden="true" 라 접근성 트리에 들어가지 않습니다 — 역할과 상태를 나르는 노드는 루트 하나뿐입니다.

name 을 주면 폼 제출용 히든 <input type="checkbox"> 가 함께 나가지만 sr-only · tabindex="-1" · aria-hidden="true" 라 역시 접근성 트리 밖입니다.

키보드#

키FlutterWeb
Space토글 전환토글 전환
Enter토글 전환토글 전환
Tab다음 포커스 가능 요소로다음 포커스 가능 요소로
Shift+Tab이전 포커스 가능 요소로이전 포커스 가능 요소로

활성화 키는 양쪽 다 합성된 Clickable 이 처리합니다. 화살표로 켜고 끄는 조작은 양 플랫폼 모두 없습니다.

비활성 토글은 양 플랫폼 모두 Tab 순회에서 빠집니다 — Flutter 는 FocusableActionDetector(enabled: false), Web 은 tabindex="-1".

스크린 리더#

접근 가능한 이름은 스위치 노드의 내용에서 옵니다 — label(또는 prefix · suffix)에 넣은 텍스트가 그대로 이름이 됩니다. 셋 다 비우면 이름이 비고, 리더는 "스위치, 켜짐" 처럼 역할과 상태만 읽습니다(아래 알려진 제약 참고).

켜짐/꺼짐은 양쪽 다 통보됩니다 — Flutter Semantics(toggled:), Web aria-checked. 값이 바뀐 사실을 따로 알리는 live region 은 없습니다; 상태는 스위치 노드 자체에 실려 있습니다.

양 플랫폼 모두 불투명도가 아니라 전용 disabled 색 토큰으로 표현합니다 — 비활성은 트랙이 말합니다: On 은 disabledActiveTrack(disabled-content), Off 는 disabledInactiveTrack(disabled-container), 라벨은 disabledForegroundColor(disabled-content). 활성 Off 트랙은 surface-container-highest 이고 disabled-container 는 그보다 낮은 컨테이너 톤이라, Off 에서도 활성/비활성은 thumb 과 라벨 색뿐 아니라 트랙 톤으로도 구분됩니다.

활성 thumb 은 자기 트랙에서 읽히는 잉크입니다 — On 은 activeThumb(on-primary), Off 는 inactiveThumb(on-surface, Off 트랙 surface-container-highest 위). 페이지 surface 를 빌려 쓰지 않으므로 어두운 페이지에서도 Off thumb 이 트랙에서 떨어져 보입니다.

비활성 thumb 은 자세마다 다릅니다. On 은 disabledThumb(surface) — On 트랙 disabled-content 위에서 보입니다. Off 는 disabledInactiveThumb(disabled-content) — surface thumb 은 disabled-container 트랙 위에서 거의 사라지기 때문입니다(CoUI 팔레트 실측 Light 1.09:1 · Dark 1.26:1). disabled-content 는 그 트랙에서 그래픽 기준 3:1 을 넘습니다.

포커스 관리#

  • 진입 — 이동 없음. 오버레이가 아니라 인라인 컨트롤이라 마운트 시 포커스를 옮기지 않습니다.
  • 이탈 — 해당 없음.
  • 트랩 — 없음. Tab 은 토글을 지나 그대로 빠져나갑니다.
  • 포커스 표시 — 링은 트랙만 감쌉니다(행 전체가 아님). 투명 간격(2px) + 링(focus-ring, 2px), 모서리 radius/9999 이며 toggleStyle.focusOutlineStyle 로 오버라이드합니다. Flutter 는 트랙을 감싼 FocusOutline (Clickable 의 행 링은 focusOutline: false), Web 은 .scope:focus-visible .co-toggle-track scoped 규칙의 outline 을 씁니다.

알려진 제약#

  • 라벨 없이 쓰면 role="switch" 노드에 이름이 붙지 않고, Web 에는 이름을 채울 다른 경로가 없습니다. 트랙과 thumb 은 aria-hidden 이라 이름은 label · prefix · suffix 의 내용에서만 오는데, Web Toggle 은 attributes · classes · css 를 생성자에서 받지 않습니다 — copyWith 가 인자로 받아들이기는 하지만 새 인스턴스로 넘기지 않습니다. Flutter 는 호출부가 Semantics(label: ...) 로 감싸 이름을 줄 수 있습니다.
  • Web 에서 CoreDuration 스케일 밖 colorTransitionDuration / thumbTransitionDuration 은 '동작 줄이기' 설정에 반응하지 않습니다. 스케일 위의 값은 var(--coui-duration-*) 로 나가 media query 가 덮지만 — 그 media query 는 전환 tier(0 초과 ~ CoreReducedMotion.transitionCeilingMs 이하)의 변수만 0 으로 덮고 표시 tier 는 값을 유지합니다 — 그 밖의 값은 인라인 {ms}ms 리터럴로 나가 덮이지 않습니다. Flutter 는 두 duration 모두 억제합니다. CoreDuration 의 alias(instant · fast …)나 msN 스케일 값을 쓰면 양쪽이 같아집니다.

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

Toggle은 coui_core의 CoreToggleVariantStyle과 CoreToggleStyle.defaultsBySize를 사용하여 Flutter/Web 동일한 디자인 토큰 기반으로 렌더링됩니다.

항목FlutterWeb
애니메이션 100ms easeInOut (AnimatedPositioned) CSS transition-transform
크기 토큰 CoreToggleStyle.defaultsBySize (pixel) CoreToggleStyle.defaultsBySize (Tailwind 어댑터)
색상 토큰 CoreToggleStyle.defaultsByVariant (CoreToggleVariantStyle) CoreToggleStyle.defaultsByVariant (CoreToggleVariantStyle)
런타임 인프라 controller / focusNode / statesController 브라우저 native focus + hidden <input>
ARIAFlutter 플랫폼 시맨틱role="switch", aria-checked
폼 연동FormValueSupplier 믹스인hidden <input>
전환 렌즈 트랙 옆 Stacks 에 OutlinedContainer(role: functional) — 진행도는 AnimationController 트랙 다음 flex 항목(마진이 크기를 상쇄) + @keyframes — 굴절은 문서 호스트가 붙임

수용된 호환 예외와 공통 호출#

Flutter는 value와 nullable onChanged 인자 전달을 요구하고 Web은 생략을 허용해 value를 false로 시작합니다. Web이 더 관대한 requiredness는 기존 호출 호환성을 위한 수용 예외입니다. 같은 controlled 호출을 공유할 때는 두 인자를 명시하세요. 이 예외는 런타임 컨트롤러·포커스 객체 타입 차이나 플랫폼별 상태 저장 정책과 별개입니다.

  • Checkbox: 독립 선택과 indeterminate 상태. 적용·저장 시점은 두 컴포넌트 모두 호출자가 결정
  • Slider: 범위 값 조절. boolean이 아닌 연속 값일 때
  • Form: 폼 컨테이너. Toggle을 폼 필드로 등록 가능

조합 예제#

// 설정 페이지 패턴
Drawer(
  title: '설정',
  content: Column(children: [
    Toggle(
      value: settings.darkMode,
      onChanged: handleDarkMode,
      label: '다크 모드',
    ),
    Toggle(
      value: settings.notifications,
      onChanged: handleNotifications,
      label: '푸시 알림',
    ),
    Toggle(
      value: settings.autoSave,
      onChanged: handleAutoSave,
      label: '자동 저장',
    ),
    Toggle(
      value: settings.analytics,
      onChanged: handleAnalytics,
      label: '사용 통계 수집',
      enabled: !settings.isGuest,
    ),
  ]),
)