Chip | CoUI
LogoCoUI

Chip

태그, 필터, 선택 상태를 표시하는 칩 컴포넌트

Chip#

태그, 필터 옵션, 선택 상태 등을 표시하는 칩 컴포넌트입니다. 삭제 버튼, 아바타, 선택 상태를 지원합니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 카테고리, 태그, 키워드를 시각적으로 표현할 때
  • 필터 옵션을 선택/해제할 수 있는 토글 가능한 레이블이 필요할 때
  • 선택된 항목 목록을 삭제 가능한 태그로 표시할 때 (예: 이메일 수신자 목록)
  • 다중 선택 UI에서 선택된 항목을 표시할 때

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

  • Badge: 상태나 숫자를 표시하는 작은 표시자가 필요할 때 (상호작용 없음)
  • Button: 클릭 시 명확한 단일 액션이 실행되어야 할 때
  • ChipInput: 텍스트를 직접 입력하여 칩을 생성하는 입력 필드가 필요할 때

기본 사용법 (Basic Usage)#

// 기본 칩
Chip(
  label: 'Flutter',
)

// 삭제 가능한 칩
Chip(
  label: 'Dart',
  onRemove: handleDeleteDart,
)

// leading 위젯 포함 칩
Chip(
  label: '홍길동',
  leading: Avatar(
    imageUrl: 'https://example.com/avatar.jpg',
    size: CoreComponentSize.xs,
  ),
  onRemove: handleDeleteUser,
)

// 선택 가능한 칩
Chip(
  label: '디자인',
  chipColor: CoreChipColor.primary,
  selected: isDesignSelected,
  onTap: handleSelectDesign,
)
// 기본 칩
Chip(label: 'Flutter')

// 삭제 가능한 칩
Chip(
  label: 'Dart',
  onRemove: handleDeleteDart,
)

// leading 포함 칩
Chip(
  label: '홍길동',
  leading: Avatar(
    initials: '홍',
    size: CoreComponentSize.xs,
  ),
  onRemove: handleDeleteUser,
)

// 선택 가능한 칩
Chip(
  label: '디자인',
  chipColor: CoreChipColor.primary,
  selected: isDesignSelected,
  onTap: handleSelectDesign,
)

Props / Parameters#

속성타입기본값설명
label String? null 칩에 표시할 텍스트
child Widget? / Component? null 커스텀 내용 (label 대체)
chipColor CoreChipColor .defaultColor (neutral) 칩 색상 변형
size CoreComponentSize md 칩 크기
selectedboolfalse선택 상태 여부
enabledbooltrue활성화 여부
onTap VoidCallback? null 탭 핸들러
onRemove VoidCallback? null 삭제 버튼 클릭 핸들러. null이면 삭제 버튼 미표시
leading Widget? / Component? null 레이블 앞 위젯
trailing Widget? / Component? null 레이블 뒤 위젯
seedColor CoreColor? null 커스텀 시드 색상
maxLength int? null 레이블 최대 글자 수
chipStyle CoreChipStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

Chip 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreChipStyle 단일 슬롯으로 흐릅니다. 시맨틱 enum (chipColor / size / selected / enabled) 과 behaviour 필드는 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / behaviour: 위젯 파라미터로 직접 (chipColor, size, selected, enabled, onTap, onRemove, label, leading, trailing, seedColor, maxLength)
  • chrome / dimensional / 슬롯 스타일: CoreChipStyle 한 곳으로 (아래 필드 표 참고)
  • close 버튼 변형 교체: asChild — 직접 Button(variant: ghost, ...) 위젯 주입은 권장 X. closeButtonStyle 로 chrome 만 조정하면 됩니다. 내부적으로 Button(variant: .ghost, size: .sm) 가 사용됩니다.

Resolve chain#

design system default for chip
  → CoreChipTheme.style                    // 프로젝트 공통
  → parent component slot override
  → widget.chipStyle                       // 인스턴스별

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

CoreChipStyle 필드#

필드타입설명
backgroundColor CoreColor? Panel background fill colour override — wins across every state. null defers to the state-keyed [defaultsByVariant] base; see the note above for why there is no default* .
foregroundColor CoreColor? Foreground (label) colour override — wins across every state, and is also the fallback the label / leading / trailing / close slots inherit when they carry no colour of their own. null defers to the state-keyed [defaultsByVariant] base; see the note above. Deliberately has no default* — absence is the design. Same state-keyed table (selectedFg / unselectedFg / disabledFg / seed) and the same absence test. Additionally it is the fallback the label, leading, trailing and close-button slots inherit when they carry no colour of their own, so pinning it silently freezes four nested slots as well.
borderColorCoreColor?Border stroke colour.
borderWidth double? Border stroke width (logical px).
borderRadius CoreBorderRadius? Border radius. When null falls back to the chip design-token radius.
paddingCoreEdgeInsets?Padding.
minHeight double? Minimum pill height (logical px). Per-size default from [defaultsBySize] — the spec's height ladder, stated as a floor so the pill matches the control tier beside it rather than its own label.
iconLabelGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the leading / label / trailing spacers. Forwarded straight to the Gap(gapStyle: …) widgets that separate the slots by the Flutter resolver; consumed by the Web resolver to emit the inline gap rem rule. null defers to CoreChipStyle.defaultsBySize[size]!.iconLabelGapStyle! .
closeIconStyle CoreIconStyle? Close-button (X) icon style. Distinct from [leadingIconStyle] / [trailingIconStyle] which target the leading / trailing content slots; this drives the icon rendered inside the onRemove close button — forwarded straight to Icon(iconStyle: …) by the resolver.
transitionDuration Duration? Transition duration for hover / focus / state colour changes. When null falls back to [defaultTransitionDuration].
castHideDuration Duration? The engage-squash pair's shared clock — how long the shadow takes to hide and the chip takes to travel into the space it vacated. null defers to [defaultCastHideDuration]. Reduced-motion collapses this to zero on both platforms — state (the hide, the travel) is unaffected.
labelStyle CoreTextStyle? Label text style override.
leadingIconStyle CoreIconStyle? Leading icon style override.
trailingIconStyle CoreIconStyle? Trailing icon style override (does not affect the close button — [closeButtonStyle] does).
closeButtonStyle CoreButtonStyle? Close ( onRemove ) button chrome. Each rendered close button uses Button(variant: ghost) internally; this slot lets the caller tweak chrome (padding / iconStyle).
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot for the Clickable composed around a tappable ( onTap != null ) chip (press scale / durations / focus ring / disabled opacity). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
customDisabledBackgroundColor CoreColor? Disabled custom-seed chip background override. null → [defaultCustomDisabledBackgroundColor].
customDisabledForegroundColor CoreColor? Disabled custom-seed chip foreground override. null → [defaultCustomDisabledForegroundColor].
customDisabledBorderColor CoreColor? Disabled custom-seed chip border override. null → [defaultCustomDisabledBorderColor].
customSelectedForegroundColor CoreColor? Selected-state custom-seed chip label colour override. null → [defaultCustomSelectedForegroundColor].
lowUnselectedForegroundColor CoreColor? Label colour override for an unselected chip at [CoreChipEmphasis.low]; falls back to [defaultLowUnselectedForegroundColor].
lowUnselectedBorderColor CoreColor? Border colour override for an unselected chip at [CoreChipEmphasis.low]; falls back to [defaultLowUnselectedBorderColor].
customLowSelectedBackgroundColor CoreColor? Fill override for a selected custom-seed chip at [CoreChipEmphasis.low] — the tint a raw seed cannot derive on its own; falls back to [defaultCustomLowSelectedBackgroundColor].
customLowSelectedForegroundColor CoreColor? Label override for a selected custom-seed chip at [CoreChipEmphasis.low]; falls back to [defaultCustomLowSelectedForegroundColor].
stateLayer CoreStateLayer? Ramp laid over the chip's own fill while hovered, focused or pressed. Defaults to [defaultStateLayer]. One ramp for every fill this chip can wear — the overlay is the chip's current foreground over its current background, so a selected chip and an unselected one wash from their own ground rather than from a shared one.
castMotion CoreCastMotion? What this chip's cast does when the chip is engaged. Null takes [defaultCastMotion]. See [CoreCastMotion] — the value says when the cast changes; what it is worth stays with the surface style.

CoreChipStyle 변형별 기본값 (CoreChipColorStyle)#

필드 neutral primary secondary tertiary
selectedBg surfaceContainerHigh primary secondary tertiary
selectedFg onSurface onPrimary onSecondary onTertiary
lowSelectedBg surfaceContainer primaryContainer secondaryContainer tertiaryContainer
lowSelectedFg onSurface onPrimaryContainer onSecondaryContainer onTertiaryContainer
unselectedBg transparent transparent transparent transparent
unselectedFg onSurface primary secondary tertiary
borderColoroutlineprimarysecondarytertiary
disabledBg disabledContainer disabledContainer disabledContainer disabledContainer
disabledFg onSurfaceVariant onSurfaceVariant onSurfaceVariant onSurfaceVariant
disabledBorderColor disabledOutline disabledOutline disabledOutline disabledOutline

사용 예 (Flutter)#

Chip(
  label: 'Selected',
  chipColor: CoreChipColor.primary,
  selected: true,
  onRemove: handleRemove,
  chipStyle: CoreChipStyle(
    padding: CoreEdgeInsets.symmetric(
      horizontal: CoreSpace.space12,
      vertical: CoreSpace.space6,
    ),
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
    labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
    closeButtonStyle: CoreButtonStyle(
      padding: CoreEdgeInsets.all(CoreSpace.space4),
    ),
  ),
)

사용 예 (Web)#

Chip(
  label: 'Selected',
  chipColor: CoreChipColor.primary,
  selected: true,
  onRemove: handleRemove,
  chipStyle: CoreChipStyle(
    padding: CoreEdgeInsets.symmetric(
      horizontal: CoreSpace.space12,
      vertical: CoreSpace.space6,
    ),
    borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
  ),
)

빠른 오버라이드 (Chain)#

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

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

  @override
  State<ChipChainExample> createState() => _ChipChainExampleState();
}

class _ChipChainExampleState extends State<ChipChainExample> {
  bool _comboSelected = false;
  bool _fullSelected = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Chip(
          label: 'Tag',
          selected: _comboSelected,
          onTap: () => setState(() => _comboSelected = !_comboSelected),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        Chip(
          label: 'Full control',
          selected: _fullSelected,
          onTap: () => setState(() => _fullSelected = !_fullSelected),
        ).withStyle(
          const CoreChipStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
            foregroundColor: CoreColor.token(CoreColors.onTertiaryContainer),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space12,
              vertical: CoreSpace.space4,
            ),
          ),
        ),
      ],
    );
  }
}
class ChipChainExample extends StatefulComponent {
  const ChipChainExample({super.key});

  @override
  State<ChipChainExample> createState() => _ChipChainExampleState();
}

class _ChipChainExampleState extends State<ChipChainExample> {
  bool _comboSelected = false;
  bool _fullSelected = false;

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        Chip(
          label: 'Tag',
          selected: _comboSelected,
          onTap: () => setState(() => _comboSelected = !_comboSelected),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
        Chip(
          label: 'Full control',
          selected: _fullSelected,
          onTap: () => setState(() => _fullSelected = !_fullSelected),
        ).withStyle(
          const CoreChipStyle(
            backgroundColor: CoreColor.token(CoreColors.tertiaryContainer),
            foregroundColor: CoreColor.token(CoreColors.onTertiaryContainer),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            padding: CoreEdgeInsets.symmetric(
              horizontal: CoreSpace.space12,
              vertical: CoreSpace.space4,
            ),
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

변형 (Variants)#

Neutral (기본)#

중립 색상의 기본 칩입니다.

Chip(label: 'Neutral', chipColor: CoreChipColor.neutral)

Primary#

강조 색상 칩입니다.

Chip(label: 'Primary', chipColor: CoreChipColor.primary)

Secondary / Tertiary#

보조 강조 칩입니다.

Chip(label: 'Secondary', chipColor: CoreChipColor.secondary)
Chip(label: 'Tertiary', chipColor: CoreChipColor.tertiary)

Custom (seedColor)#

seedColor 로 임의 색을 파생시킵니다. chipColorcustom 으로 두고 씨드 색을 넘깁니다.

Chip(
  label: 'Custom',
  chipColor: CoreChipColor.custom,
  seedColor: CoreColor.token(CoreColors.tertiary),
)

칩 그룹#

여러 칩을 필터 그룹으로 묶어 사용할 수 있습니다.

Wrap(
  spacing: CoreSpace.space8,
  children: [
    Chip(label: 'Flutter', onRemove: () => handleDelete('flutter')),
    Chip(label: 'Dart', onRemove: () => handleDelete('dart')),
    Chip(label: 'UI', onRemove: () => handleDelete('ui')),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • 클릭/탭 (선택형): onTap 콜백 실행. selected 상태 갱신은 부모에서 처리
  • 삭제 버튼 클릭: onRemove 콜백 실행. 칩 제거는 부모에서 처리
  • 호버: 배경색 미세 변화로 상호작용 가능 상태 표시
  • 포커스: 합성된 Clickable 의 포커스 링 표시

상태 전환#

  • defaulthoverpresseddefault
  • selected 상태 렌더는 selected 파라미터가 결정 (컴포넌트가 자체 토글하지 않음)
  • enabled: false: 클릭 불가, 전용 disabled 토큰(disabledBg / disabledFg / disabledBorderColor)으로 렌더

애니메이션#

  • 선택/호버 색 전환: chipStyle.transitionDuration (기본 CoreDuration.instant = 100ms)
  • 삭제 시 (부모 목록에서): AnimatedSize 또는 AnimatedOpacity 사용 권장

캐스트 모션 (castMotion)#

chipStyle.castMotion: CoreCastMotion? 은 칩이 engage 될 때 그림자(캐스트)가 언제 · 어느 쪽으로 바뀌는지를 정합니다. 세 값입니다:

restengage참조 이름
defaultMotion (기본) 캐스트가 있음 캐스트가 사라지고, 칩이 그 자리로 이동 default
reverse 캐스트가 없음 캐스트가 생기고, 칩이 반대 방향으로 이동 reverse
noShadow캐스트 없음변화 없음 (그림자도 이동도)noShadow

defaultMotionreverse 는 그림자 유무와 이동 방향이 하나의 짝으로 묶여 있습니다 — 숨김과 이동을 다른 시계에 태우면 한 번의 누름이 두 개의 사건으로 보입니다. 탭 핸들러가 없는(onTap == null, 예: ChipInput 안의 표시 전용 칩) 칩은 애초에 engage 할 수단이 없으므로, reverse 를 고르면 캐스트가 rest 그대로 계속 숨어 있습니다 — engage 없이 자라날 수 없기 때문입니다.

engage — hover 인가 press 인가

Button 과 동일하게, "engage" 는 하나의 입력이 아닙니다. 포인터가 있는 기기에서는 hover, 없는 기기에서는 press 가 engage 를 켭니다.

기기engage 트리거
포인터 있음 (마우스 · 트랙패드)hover
포인터 없음 (터치 전용)press

이 축이 참조하는 디자인은 웹 전용 라이브러리라 :hover 만 말합니다. 그대로 옮기면 터치 기기에서 깨집니다 — 터치의 :hover 는 브라우저가 탭을 흉내 내어 켜고 다음 탭까지 꺼지지 않으므로, 칩이 탭 이후 계속 눌린 모양으로 굳어 있게 됩니다. 그래서 데스크톱은 hover, 터치는 press 로 갈립니다 — Web 은 @media (hover: hover), Flutter 는 hover 이벤트가 데스크톱에만 도달하는 성질을 그대로 씁니다.

Button 과 다른 점 — 채움 게이트가 없습니다

Button 은 배경도 테두리도 그리지 않는 variant(ghost/link/text/plain)에서 캐스트를 걸러내는 채움 게이트를 갖고, 호출자가 명시한 boxShadow 가 이 축을 이깁니다. Chip 에는 둘 다 없습니다 — CoreChipStyleboxShadow 슬롯 자체가 없고(칩은 자기 그림자를 직접 조합하는 용도가 아님), 모든 chipColor variant 가 기본으로 테두리(borderWidth 기본값 stroke1)를 그리므로 "던질 박스가 없는" 상태가 되지 않습니다. 그래서 castMotion 은 활성 스타일이 캐스트를 publish 하기만 하면 배경 채움 여부와 무관하게 항상 적용됩니다 — chipStyle.borderWidth0 으로 직접 낮추고 배경도 투명으로 둔 경우는 예외이며, 그 조합에서는 박스 없이 그림자만 뜬 모양이 될 수 있습니다.

눈에 보이려면 스타일이 캐스트를 publish 해야 합니다

castMotion언제 캐스트가 바뀌는지만 말합니다. 캐스트 자체(오프셋 · 색 · 두께)는 서페이스 스타일 이 진술합니다. 오늘은 Neo-brutalism 프리셋만 이 값을 publish 합니다 — 나머지(Default · Liquid Glass · Neumorphism · Claymorphism) 아래에서는 castMotion 을 무엇으로 바꾸든 화면이 그대로입니다. 위 라이브 프리뷰가 아무 변화도 안 보인다면 결함이 아니라 활성 프리셋이 이 축을 publish 하지 않는다는 뜻입니다 — 프리셋 스위처로 Neo-brutalism 을 켜면 세 값의 차이가 드러납니다.

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

✅ Do#

필터 그룹에서 선택형 칩 사용

Wrap(
  spacing: CoreSpace.space8,
  children: categories.map((cat) => Chip(
    label: cat.name,
    chipColor: CoreChipColor.primary,
    selected: selectedCategories.contains(cat.id),
    onTap: () => handleToggleCategory(cat.id),
  )).toList(),
)

선택형 칩은 필터 옵션을 토글하는 데 직관적인 UI를 제공합니다.


❌ Don't#

칩에 긴 텍스트 사용 금지

// ❌ 너무 긴 레이블
Chip(
  label: '사용자가 최근에 방문한 카테고리 항목', // 너무 길다
)

칩은 짧고 간결한 레이블에 최적화되어 있습니다. 긴 텍스트는 레이아웃을 깨뜨리고 가독성을 저해합니다. 2~3단어 이내로 제한하세요.

✅ Do#

삭제 가능한 칩 목록에 애니메이션 적용

AnimatedList(
  initialItemCount: tags.length,
  itemBuilder: (context, index, animation) => SizeTransition(
    sizeFactor: animation,
    child: Chip(
      label: tags[index],
      onRemove: () => handleRemoveTag(index),
    ),
  ),
)

칩 삭제 시 자연스러운 애니메이션은 사용자에게 변화를 시각적으로 전달합니다.


❌ Don't#

비활성화된 칩에 삭제 버튼 표시 금지

// ❌ enabled: false 이지만 onRemove 가 있음
Chip(
  label: '읽기 전용 태그',
  enabled: false,
  onRemove: handleDelete, // disabled 상태에서는 의미 없음
)

비활성화된 칩에 삭제 버튼이 있으면 사용자가 혼란스러워합니다. enabled: false 일 때는 onRemove 를 null 로 설정하세요.

✅ Do#

Chip의 label은 간결하게 작성하세요.

// ✅ 짧고 명확한 레이블
Chip(label: '모바일', onTap: handleMobileSelected)
Chip(label: 'UX', onTap: handleUxSelected)
Chip(label: 'Flutter', onTap: handleFlutterSelected)

Chip은 키워드나 태그처럼 짧은 텍스트를 표시하는 용도입니다. 간결한 레이블이 스캔과 선택을 쉽게 만듭니다.


❌ Don't#

Chip에 긴 문장을 넣지 마세요.

// ❌ 너무 긴 Chip 레이블
Chip(
  label: '사용자가 직접 등록한 관심 카테고리',  // 너무 길다
  onTap: handleCategorySelected,
)

긴 텍스트는 Chip의 시각적 일관성을 깨고 레이아웃을 혼란스럽게 만듭니다. Badge나 Tag 컴포넌트를 고려하세요.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Enter / Space선택형 칩 토글
Delete / Backspace삭제 가능한 칩 제거
Tab다음 칩 또는 삭제 버튼으로 이동

스크린 리더#

  • Flutter: Semanticsrole="checkbox" (선택형) 또는 role="button" (삭제형) 전달
  • Web: 선택형 칩에 aria-selected, 삭제 버튼에 aria-label="[레이블] 제거" 적용

터치 타겟#

  • 칩은 작은 사이즈에서 24 (CoreTouchTarget.minimum) 아래로 그려지므로, TouchTarget(Flutter) / coui-touch-target(Web)이 그리는 크기는 그대로 둔 채 닿는 범위만 그 하한까지 넓힙니다.
  • 삭제 버튼 별도 터치 영역: 최소 24x24dp

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

항목FlutterWeb
그룹 레이아웃Wrap 위젯flexbox 자동 처리
색 전환 AnimatedContainer (transitionDuration) CSS transition (동일 토큰)
  • ChipInput: 텍스트 입력으로 칩을 동적으로 추가하는 입력 필드
  • Badge: 상호작용 없이 상태나 숫자만 표시할 때 사용
  • Button: 클릭 시 명확한 단일 액션이 실행되어야 할 때

조합 예제#

// Chip + ChipInput 조합으로 태그 입력 UI 구현
Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Wrap(
      spacing: CoreSpace.space8,
      runSpacing: CoreSpace.space4,
      children: selectedTags.map((tag) => Chip(
        label: tag,
        onRemove: () => handleRemoveTag(tag),
      )).toList(),
    ),
    Gap(gapStyle: CoreGapStyle(size: CoreSpace.space8)),
    ChipInput(
      placeholder: '태그 추가...',
      onSubmitted: handleAddTag,
    ),
  ],
)