ColorPicker | CoUI
LogoCoUI

ColorPicker

색상을 선택하고 편집하는 색상 선택기 컴포넌트

ColorPicker#

다양한 형식으로 색상을 선택하고 편집할 수 있는 색상 선택기 컴포넌트입니다. Flutter 와 Web 모두 ColorPicker 로 통일된 API (color / onChanged / showAlpha / showLabel / showHexInput / pickerMode 등) 를 제공하며, 공통 CoreColorPickerTheme / CoreColorPickerStyle 토큰을 공유합니다. trigger 는 양쪽 모두 [swatch][hex input?] 행으로 동일한 사이즈(40px swatch · 96px hex input · 4px swatch gap) 를 사용합니다.

값 표현은 플랫폼 컨벤션을 따릅니다 — Flutter 는 Color (Color(0xFF3B82F6)), Web 은 #RRGGBB 형식의 hex 문자열. 내부 색상 연산은 양쪽 모두 hex 기반 (core_color_math.dart) 으로 통일되어 있어 정확도와 동작이 동일합니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 사용자가 브랜드 색상, 테마 색상 등 임의의 색상을 직접 지정해야 하는 경우
  • 디자인 도구, 에디터 등에서 색상 팔레트를 제공해야 하는 경우
  • HEX 값이나 RGB / HSL / HSV 중 특정 색공간으로 값을 입력받아야 하는 경우

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

  • Select: 고정된 색상 목록에서만 선택해야 하는 경우
  • TextField: 색상 코드를 순수 텍스트로만 입력받는 경우

기본 사용법 (Basic Usage)#

// Color 값 기반 — Flutter 컨벤션
ColorPicker(
  color: Color(0xFF3B82F6),
  onChanged: (color) => setState(() => _color = color),
  showHexInput: true,
)

// RGB / HSL / HSV 기본 모드 + 알파 슬라이더 + 화면 색 추출
ColorPicker(
  color: _color,
  onChanged: handleColorChanged,
  showAlpha: true,
  showLabel: true,
  pickerMode: CoreColorPickerMode.rgb,
  enableEyeDropper: true,
)
// hex 문자열 기반 — CSS 컨벤션
ColorPicker(
  color: '#3B82F6',
  onChanged: (hex) => setState(() => _color = hex),
  showHexInput: true,
)

ColorPicker(
  color: _color,
  onChanged: handleColorChanged,
  showAlpha: true,
  showLabel: true,
  pickerMode: CoreColorPickerMode.rgb,
  enableEyeDropper: true,
)

Props / Parameters#

공통 Contract 필드 (CoreColorPickerContract):

속성FlutterWeb기본값설명
color Color String 필수 현재 선택된 색상 (Flutter Color, Web #RRGGBB)
onChanged ValueChanged<Color>? CoreValueChanged<String>? null Save 커밋 시 호출되는 콜백 (live-preview 에는 호출되지 않음)
enabled bool? bool? true 인터랙션 가능 여부
showAlpha bool? bool? false 알파 슬라이더 표시
showLabel bool? bool? false swatch 옆 hex 라벨 표시
showHexInput bool? bool? false swatch 옆 hex 텍스트 입력 표시
pickerMode CoreColorPickerMode? CoreColorPickerMode? rgb RGB / HSL / HSV 색공간
enableEyeDropper bool? bool? true EyeDropper 버튼 표시 (Web 은 Chromium 95+ / Flutter 는 web target 에서 동작)
promptMode CoreColorPickerPromptMode? CoreColorPickerPromptMode? popover 트리거 클릭 시 popover/dialog 중 어떤 surface로 열릴지
dialogTitle Widget? Component? null 팝오버 / 다이얼로그 상단 타이틀
placeholder Widget? Component? null 미선택 상태 placeholder
controller Object? Object? null 외부 imperative controller
colorPickerStyle CoreColorPickerStyle? CoreColorPickerStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

ColorPicker 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreColorPickerStyle 단일 슬롯으로 흐릅니다. 평면 chrome 필드 (popoverAlignment / popoverAnchorAlignment / popoverPadding) 는 모두 제거되었습니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / behaviour: 위젯/컴포넌트 파라미터로 직접 (color, onChanged, enabled, showAlpha, showLabel, showHexInput, pickerMode, enableEyeDropper, promptMode, dialogTitle, placeholder, controller)
  • chrome / dimensional / 슬롯 스타일: CoreColorPickerStyle 한 곳으로 (popoverStyle / dialogStyle / swatchButtonStyle / hexInputStyle / labelStyle / descriptionStyle / errorStyle)
  • 변형(variant) 교체: asChild — dialogTitle / placeholder 슬롯 등에 직접 위젯 주입

Resolve chain#

design system default for color picker
  → CoreColorPickerTheme.style                  // 프로젝트 공통
  → parent component slot override
  → widget.colorPickerStyle                     // 인스턴스별

각 nested 슬롯 스타일 (popoverStyle / dialogStyle / swatchButtonStyle / hexInputStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다. 예: popoverStyle.triggerStyle 의 chrome 은 → CoreButtonTheme → variant 룩업 → 부모 슬롯 → widget.colorPickerStyle.popoverStyle.triggerStyle 순.

CoreColorPickerStyle 필드#

필드타입설명
swatchSize double? Trigger swatch button width/height override (logical px).
hexInputWidth double? Hex input field width override (logical px).
swatchGapStyle CoreGapStyle? Horizontal gap between swatch and hex input override — nested [CoreGapStyle] slot forwarded to Gap(gapStyle: …) .
popoverWidth double? Popover panel width override (logical px).
popoverSpacing double? Vertical spacing between popover sections override (logical px, pre-scaling). Raw primitive — flows directly into Column.spacing (Flutter) and the flex flex-col parent's CSS gap (Web), not a Gap slot. Falls back to [defaultPopoverSpacing] when null.
sliderHeight double? Hue / alpha slider track height override (logical px).
modeInputSpacing double? Horizontal spacing between RGB / HSL / HSV mode input cells override (logical px, pre-scaling). Raw primitive — flows directly into Row.spacing (Flutter) and the flex parent's CSS gap (Web), not a Gap slot. Falls back to [defaultModeInputSpacing] when null.
modeInputLabelGapStyle CoreGapStyle? Vertical 1-off gap between a mode input cell's label and its number field override — nested [CoreGapStyle] slot forwarded to Gap(gapStyle: …) . The label/field stacking is a single 1-off spacer internal to the mode input cell on both platforms.
actionRowGapStyle CoreGapStyle? Horizontal gap between Cancel / Save buttons in the action row override — nested [CoreGapStyle] slot forwarded to Gap(gapStyle: …) .
eyeDropperIconStyle CoreIconStyle? Eye-dropper button icon style override — nested [CoreIconStyle] slot forwarded to Icon(iconStyle: …) . Falls back to [defaultEyeDropperIconStyle] when null.
recentSwatchSize double? Recent-color swatch side length override (logical px).
recentSwatchSpacing double? Spacing (logical px) between recent-color swatches override — raw primitive, drives Wrap.spacing/runSpacing (Flutter) and CSS gap (Web), not a Gap slot.
recentColumns int? Recent-colors grid column count override.
swatchBorderRadius CoreBorderRadius? Corner radius override for the trigger swatch button, hue / alpha sliders, and recent-color swatches.
saturationValueBorderRadius CoreBorderRadius? Corner radius override for the 2D saturation/value area inside the popover.
borderColor CoreColor? Border colour override for the trigger swatch, the 2D saturation/value area, and the hue / alpha slider tracks. Falls back to [defaultBorderColor] ( outline token) when null.
borderWidth double? Border stroke width override (logical px) for the trigger swatch, the 2D saturation/value area, and the hue / alpha slider tracks. Falls back to [defaultBorderWidth] when null.
disabledBorderColor CoreColor? Border colour override for the trigger swatch when disabled. Falls back to [defaultDisabledBorderColor] ( disabledOutline token) when null.
selectedRingColor CoreColor? Ring colour override for the currently-selected recent-color swatch. Falls back to [defaultSelectedRingColor] ( primary token) when null.
recentSwatchBorderWidth double? Border stroke width override (logical px) for an unselected recent-color swatch. Falls back to [defaultRecentSwatchBorderWidth] when null.
recentSwatchSelectedBorderWidth double? Border stroke width override (logical px) for the currently-selected recent-color swatch. Falls back to [defaultRecentSwatchSelectedBorderWidth] when null.
transitionDuration Duration? Transition duration override for the swatch hover / focus colour transitions and the recent-swatch ring transition. Falls back to [defaultTransitionDuration] when null.
popoverStyle CorePopoverStyle? Popover style for the popover-mode picker (trigger + dropdown chrome).
dialogStyle CoreDialogStyle? Dialog style for the modal-mode picker. Read by no resolver and no widget — wire it or remove it. Wire it, don't default it. Both resolvers forward it ( dialogStyle: merged.dialogStyle ) into ResolvedColorPicker.dialogStyle , but no widget reads that field and neither platform composes a Dialog : promptMode:.dialog renders the same popover content column (Flutter _buildPopoverContent -equivalent SizedBox+Column, Web _buildPopoverContent ), only with dialogTitle prepended. So the real defect is the missing Dialog composition for promptMode:.dialog (style-contract.md facet 4: modal centre surface must be Dialog , not a re-drawn panel) — fixing that makes the slot live and gives it a natural partial default. Until then it must NOT get a default: a value on a slot nothing reads is an orphan constant, the mirror violation. Deletion would also be viable but is a breaking public-field removal and not this PR's call. NOTE: CoreColorPickerStyle.defaultHexInputStyle 's doc comment already asserts 'no platform composes a Dialog' — that claim is correct and is corroborated here.
swatchButtonStyle CoreButtonStyle? Style applied to each preset / history color swatch button. Carries no defaultSwatchButtonStyle . No platform composes a Button for the swatch — both read exactly two sub-fields off this slot as the first rung of a precedence chain over the flat swatch geometry: merged.swatchButtonStyle?.width ?? merged.swatchSize ?? defaultSwatchSize and merged.swatchButtonStyle?.borderRadius ?? merged.swatchBorderRadius ?? defaultSwatchBorderRadius . A default setting either sub-field would outrank [defaultSwatchSize] / [defaultSwatchBorderRadius] and make those two named defaults — the values actually painted — unreachable.
recentSwatchClickableStyle CoreClickableStyle? Nested style for the composed recent-color swatch Clickable — carries any press / focus-ring / cursor chrome overrides for the swatch affordance. Merged on top of [defaultRecentSwatchClickableStyle] and raw-forwarded to Clickable(clickableStyle:) .
hexInputStyle CoreTextFieldStyle? Style applied to the hex input field (showHexInput: true).
labelStyle CoreTextStyle? Label text style (text shown above the trigger). Read by no resolver and no widget — wire it or remove it. Wire it or delete it — do not default it. Forwarded into ResolvedColorPicker.labelStyle on both platforms and read by nobody: ColorPicker has no label slot at all (its showLabel bool only gates the hex TextField 's visibility alongside showHexInput , and the internal mode-input captions use a Text(...).bodySmall.onSurfaceVariant chain, not this slot). Cleanest resolution is to let ColorPicker compose FormField , which already owns label/description/error and their defaults, and drop these three slots; otherwise add real label widget slots on both platforms. A default on an unread slot would just be an orphan constant.
descriptionStyle CoreTextStyle? Description / helper text style. Read by no resolver and no widget — wire it or remove it. Same as labelStyle : forwarded into ResolvedColorPicker.descriptionStyle on both platforms, read by nobody, and ColorPicker renders no description/helper text node on either platform. Route through a composed FormField (which owns defaultDescriptionTextStyle ) or remove in a breaking release; do not give it a value while nothing reads it.
errorStyle CoreTextStyle? Error text style. Read by no resolver and no widget — wire it or remove it. Same as labelStyle : forwarded into ResolvedColorPicker.errorStyle on both platforms, read by nobody, and ColorPicker renders no error text node on either platform. Per widget/failure-surfaces.md the error surface belongs to the composed FormField / ValidatorHint , so route it there or remove in a breaking release; do not give it a value while nothing reads it.

Migration — 옛 평면 필드 → 새 위치 매핑#

옛 chrome 필드새 위치
popoverAlignment popoverStyle.placement (또는 Popoverplacement 직접 지정)
popoverAnchorAlignmentpopoverStyle.placement
popoverPaddingpopoverStyle.panelPadding

사용 예 (Flutter)#

ColorPicker(
  color: const Color(0xFF3B82F6),
  showHexInput: true,
  onChanged: handleColorChanged,
  colorPickerStyle: CoreColorPickerStyle(
    popoverStyle: CorePopoverStyle(
      panelBorderRadius: 12,
      panelPadding: 16,
      gap: 8,
    ),
    swatchButtonStyle: CoreButtonStyle(
      borderRadius: 4,
    ),
    hexInputStyle: CoreTextFieldStyle(
      borderColor: Color(0xFF3B82F6),
    ),
    labelStyle: CoreTextStyle(fontWeight: 600),
  ),
)

사용 예 (Web)#

ColorPicker(
  color: '#3B82F6',
  showHexInput: true,
  onChanged: handleColorChanged,
  colorPickerStyle: CoreColorPickerStyle(
    popoverStyle: CorePopoverStyle(
      panelBorderRadius: 12,
      panelPadding: 16,
    ),
    hexInputStyle: CoreTextFieldStyle(
      borderColor: 'primary',
    ),
  ),
)

빠른 오버라이드 (Chain)#

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

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

  @override
  State<ColorPickerChainExample> createState() => _ColorPickerChainExampleState();
}

class _ColorPickerChainExampleState extends State<ColorPickerChainExample> {
  Color _color = const Color(0xFF3B82F6);

  @override
  Widget build(BuildContext context) {
    return ColorPicker(
      color: _color,
      showHexInput: true,
      onChanged: (value) {
        setState(() => _color = value);
      },
    ).withStyle(
      const CoreColorPickerStyle(
        swatchSize: CoreSpace.space48,
        hexInputWidth: CoreSpace.space112,
        swatchGapStyle: CoreGapStyle(size: CoreSpace.space12),
        swatchBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        borderColor: CoreColor.token(CoreColors.primary),
        borderWidth: CoreStrokeWidth.stroke2,
      ),
    );
  }
}
class ColorPickerChainExample extends StatefulComponent {
  const ColorPickerChainExample({super.key});

  @override
  State<ColorPickerChainExample> createState() => _ColorPickerChainExampleState();
}

class _ColorPickerChainExampleState extends State<ColorPickerChainExample> {
  String _color = '#3B82F6';

  @override
  Component build(BuildContext context) {
    return ColorPicker(
      color: _color,
      showHexInput: true,
      onChanged: (value) {
        setState(() => _color = value);
      },
    ).withStyle(
      const CoreColorPickerStyle(
        swatchSize: CoreSpace.space48,
        hexInputWidth: CoreSpace.space112,
        swatchGapStyle: CoreGapStyle(size: CoreSpace.space12),
        swatchBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        borderColor: CoreColor.token(CoreColors.primary),
        borderWidth: CoreStrokeWidth.stroke2,
      ),
    );
  }
}

변형 (Variants)#

swatch + hex 텍스트 입력#

ColorPicker(
  color: const Color(0xFF3B82F6),
  showHexInput: true,
  onChanged: handleChanged,
)

EyeDropper#

ColorPicker(
  color: _color,
  enableEyeDropper: true,
  onChanged: handleChanged,
)

enableEyeDropper: true + 호스트가 window.EyeDropper API 를 지원하면 액션 행에 스포이드 버튼 표시. Chromium 95+ / Flutter Web target 에서 동작. 미지원 환경 (Firefox · Safari · 네이티브 Flutter) 에서는 버튼이 자동 숨김.

최근 색 (Recent Colors)#

ColorPicker 는 같은 화면 안에서 commit 한 색상을 RecentColorsScope 를 통해 공유합니다. picker 호스팅 화면을 scope 로 wrap 하면 그 안의 모든 picker 가 동일 history 를 보고, Save 액션이 새 색을 push 하면 다른 picker 의 그리드에 즉시 반영됩니다.

// Flutter
RecentColorsScope(
  initialRecentColors: [
    CoreColor.fromHex('#3B82F6'),
    CoreColor.fromHex('#E11D48'),
  ],
  maxRecentColors: 10,
  onRecentColorsChanged: (colors) => persist(colors),
  child: ColorPaletteEditor(...),
)

// Web — 동일 시그니처
RecentColorsScope(
  initialRecentColors: [
    CoreColor.fromHex('#3B82F6'),
    CoreColor.fromHex('#E11D48'),
  ],
  maxRecentColors: 10,
  onRecentColorsChanged: (colors) => persist(colors),
  child: ColorPaletteEditor(...),
)

scope 바깥에서 단독으로 쓰는 picker (예: 스토리북, 단일 다이얼로그) 는 history 그리드 영역이 자동으로 숨겨지고 onChanged Save 콜백만 발사됩니다. App-level prop 이 아닌 picker 호스팅 화면 단위로 wrap 하는 패턴이 정공법 — root 가 아닌 scope 가 필요한 곳에서만 적용됩니다.

그리드 토큰#

그리드 swatch 사이즈와 컬럼 수는 CoreColorPickerStyledefaultRecentSwatchSize / defaultRecentSwatchGap / defaultRecentColumns 에서 결정됩니다 (기본 24px swatch · 4px gap · 10 columns). 양 플랫폼 grid 가 같은 토큰 source 를 참조합니다.

동작 스펙 (Behavior)#

인터랙션#

  • swatch 클릭: 통일된 Popover 팝오버 열림 (양 플랫폼 동일 인프라)
  • hex 입력: #RRGGBB 형식 검증 후 _commit 발사
  • mode tab (RGB / HSL / HSV): Button(.primary | .ghost) 사용 — hover / focus / disabled 인터랙션 통일 컴포넌트가 처리
  • SV (Saturation / Value) 영역: 마우스 드래그로 saturation / value 동시 조정 (cursor: precise / crosshair)
  • hue 슬라이더: 좌우 드래그로 hue 조정 (cursor: ew-resize)
  • EyeDropper: 지원 환경에서 화면 픽셀 샘플링
  • Outside tap / ESC: Save 안 누른 상태로 닫히면 draft 가 마지막 commit 색으로 자동 롤백

상태 전환#

  • defaultpicking: swatch 클릭 시 (Popover open)
  • pickingcommitted: Save 버튼 클릭. onChanged 호출 + RecentColorsScopeaddHistory
  • pickingcancelled: Cancel · outside-tap · ESC. swatch / hex input 이 마지막 committed 색으로 롤백
  • disabled: 트리거 row 비활성, 팝오버 mount 안 됨 (aria-disabled="true")

색 commit 흐름#

swatch click → popover open → live preview drag/type → Save_commit(hex)
                                                                    ↓
                                                              widget.onChanged(color/hex)
                                                                    ↓
                                                              storage.addHistory(CoreColor)

onChanged 는 Save 시점에만 호출되어 의도하지 않은 미커밋 값이 외부 상태로 새지 않습니다.

Theme 오버라이드#

CoreComponentTheme.colorPicker 를 통해 프로젝트 레벨의 기본 스타일 (CoreColorPickerStyle) 을 지정할 수 있습니다. behaviour 기본값 (showAlpha / showLabel / showHexInput / pickerMode / enableEyeDropper / promptMode) 은 위젯 파라미터로 직접 지정하며, 미지정 시 CoreColorPickerContract.defaultX 단일 출처 기본값을 따릅니다:

CoreComponentTheme(
  colorPicker: CoreColorPickerTheme(
    style: CoreColorPickerStyle(
      popoverStyle: CorePopoverStyle(
        panelBorderRadius: 8,
        panelPadding: 16,
      ),
    ),
  ),
)

// behaviour 기본값은 위젯 파라미터로:
ColorPicker(
  color: color,
  onChanged: handleChange,
  showAlpha: true,
  showHexInput: true,
)

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

✅ Do#

RecentColorsScope 는 picker 를 호스팅하는 화면 단위로 감싸기

RecentColorsScope(
  initialRecentColors: [
    CoreColor.fromHex('#3B82F6'),
    CoreColor.fromHex('#E11D48'),
  ],
  maxRecentColors: 10,
  onRecentColorsChanged: (colors) => persist(colors),
  child: ColorPaletteEditor(...),
)

App-level(root)에 걸면 서로 무관한 화면의 picker 들이 같은 history 를 공유하게 됩니다 — scope 가 필요한 화면 단위로만 감싸야 의도한 범위에서만 최근 색이 공유됩니다.


❌ Don't#

onChanged를 실시간 드래그 미리보기 트리거로 쓰지 않기

// ❌ SV 영역 드래그·hue 슬라이더 조작마다 호출될 거라 기대
ColorPicker(
  color: color,
  onChanged: (c) => applyLivePreview(c),
)

onChanged는 Save 커밋 시점에만 호출됩니다 — 팝오버 안에서의 드래그/타이핑 같은 실시간 조작 중에는 호출되지 않으므로, 의도치 않은 미커밋 값이 외부 상태로 새지 않습니다.

접근성 (Accessibility)#

  • 트리거 래퍼: role="group" / aria-label="Color picker"
  • 스와치: <button>. 클릭 · 키보드 · 포커스는 Popover의 트리거 핸들러가 담당합니다. aria-haspopup="dialog" / aria-expanded는 팝오버 래퍼가 emit합니다.
  • 팝오버 패널: role="dialog" / aria-label="Color picker"
  • 모드 탭: <button>Button의 표준 포커스 / 호버
  • 비활성: aria-disabled="true" + disabled

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

API 와 시각 패리티는 통일됨. 플랫폼 간 차이는 구현 매체값 표현 만 다르며 기능 동등.

항목FlutterWeb
클래스명ColorPickerColorPicker
색상 타입ColorString (hex)
Style 제네릭 CoreColorPickerStyle CoreColorPickerStyle
색공간 선택CoreColorPickerModeCoreColorPickerMode
팝오버 인프라 Popover (overlay-driven) Popover (OverlayHost portal)
EyeDropperWeb target 에서 window.EyeDropper API 호출동일
외부 클릭 / ESC 닫기 PopoverdismissOnOutsideTap / dismissOnEscape 동일
RecentColorsScope Data<CoreColorHistoryStorage> InheritedWidget _RecentColorsInherited InheritedComponent
  • TextField: 색상 코드를 직접 텍스트로 입력
  • Slider: 개별 채널 값을 조정
  • Popover: ColorPicker 가 사용하는 팝오버 인프라