LogoSkills

coui-chip-input

Activate when creating chip inputs, tag inputs, multi-value text fields, token fields, or email-recipient inputs using the ChipInput widget with CoreChipInputStyle, CoreChipInputTheme, CoreChipInpu...

CoUI ChipInput#

ํ•œ๋งˆ๋””๋กœ (๋น„๊ฐœ๋ฐœ์ž์šฉ)#

์ด ๊ธฐ๋Šฅ์€ ์ž…๋ ฅ์นธ์— ๋‹จ์–ด๋ฅผ ์ ๊ณ  Enter๋ฅผ ๋ˆ„๋ฅด๋ฉด ๊ทธ ๋‹จ์–ด๊ฐ€ ์ž‘์€ ์•Œ์•ฝ ๋ชจ์–‘ ํƒœ๊ทธ("์นฉ")๋กœ ๋ฐ”๋€Œ์–ด ๋‚˜๋ž€ํžˆ ์Œ“์ด๋Š” ์ž…๋ ฅ ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด ์ค๋‹ˆ๋‹ค. ๋ฉ”์ผ ๋ณด๋‚ผ ๋•Œ ๋ฐ›๋Š” ์‚ฌ๋žŒ์„ ํ•œ ๋ช…์”ฉ ์ถ”๊ฐ€ํ•˜๋ฉด ๋™๊ทธ๋ž€ ์ด๋ฆ„ํ‘œ๊ฐ€ ์ƒ๊ธฐ๋Š” ๊ฒƒ, ๋˜๋Š” ๋ธ”๋กœ๊ทธ ๊ธ€์— ํ•ด์‹œํƒœ๊ทธ๋ฅผ ์—ฌ๋Ÿฌ ๊ฐœ ๋‹ค๋Š” ๊ฒƒ์„ ๋– ์˜ฌ๋ฆฌ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๊ฐ ์นฉ์—๋Š” X ๋ฒ„ํŠผ์ด ์žˆ์–ด ์†์‰ฝ๊ฒŒ ์ง€์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฌด์—‡์„ยท์–ธ์ œ (๋น„๊ฐœ๋ฐœ์ž์šฉ)#

  • ๋ฌด์—‡์„: ์—ฌ๋Ÿฌ ๊ฐœ์˜ ๊ฐ’(ํƒœ๊ทธ, ์ด๋ฉ”์ผ, ๊ด€์‹ฌ ๋ถ„์•ผ ๋“ฑ)์„ ํ•˜๋‚˜์˜ ์ž…๋ ฅ์นธ์— ์ฐจ๊ณก์ฐจ๊ณก ๋‹ด์„ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด ์ค๋‹ˆ๋‹ค. ์ž…๋ ฅํ•œ ๊ฐ’์€ ๊ฐ๊ฐ ์ง€์šธ ์ˆ˜ ์žˆ๋Š” ์นฉ์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.
  • ์–ธ์ œ: ํƒœ๊ทธ ์ž…๋ ฅ, ๋‹ค์ค‘ ๊ฐ’ ์ž…๋ ฅ์นธ, ์ด๋ฉ”์ผ ๋ฐ›๋Š” ์‚ฌ๋žŒ ๋ชฉ๋ก์ฒ˜๋Ÿผ "ํ•œ ์นธ์— ์—ฌ๋Ÿฌ ๊ฐœ๋ฅผ ๋„ฃ์–ด์•ผ ํ•˜๋Š”" ํ™”๋ฉด์„ ๋งŒ๋“ค ๋•Œ ์ด ๊ธฐ๋Šฅ์ด ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.
  • ์˜ต์…˜: ์ตœ๋Œ€ ๊ฐœ์ˆ˜ ์ œํ•œ(์˜ˆ: ์ตœ๋Œ€ 5๊ฐœ โ€” ๊ฝ‰ ์ฐจ๋ฉด ์ž…๋ ฅ์นธ์ด ์ž ์‹œ ์‚ฌ๋ผ์ง), ์•ˆ๋‚ด ๋ฌธ๊ตฌ(placeholder โ€” ํ˜„์žฌ๋Š” ์›น ํ™”๋ฉด์—์„œ๋งŒ ํ‘œ์‹œ๋จ), ์ž…๋ ฅ ์ž ๊ธˆ(์ฝ๊ธฐ ์ „์šฉ), ์ƒ‰์ƒยท๋ชจ์–‘ ๊ฐ™์€ ๋””์ž์ธ ์กฐ์ •๋„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์–ด๋””์„œ: ๋ชจ๋ฐ”์ผ/์•ฑ ํ™”๋ฉด(Flutter)๊ณผ ์›น ํ™”๋ฉด(Web)์—์„œ ๊ฑฐ์˜ ๋˜‘๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ์šฉ์–ด (๋น„๊ฐœ๋ฐœ์ž์šฉ)#

์šฉ์–ด์‰ฌ์šด ์„ค๋ช…
์นฉ(Chip)์ž…๋ ฅํ•œ ๋‹จ์–ด ํ•˜๋‚˜๊ฐ€ ๋ฐ”๋€Œ์–ด ํ‘œ์‹œ๋˜๋Š” ์•Œ์•ฝ ๋ชจ์–‘์˜ ์ž‘์€ ์ด๋ฆ„ํ‘œ. X ๋ฒ„ํŠผ์œผ๋กœ ์ง€์šธ ์ˆ˜ ์žˆ์Œ
ChipInput์ด๋Ÿฐ ์นฉ๋“ค์„ ์—ฌ๋Ÿฌ ๊ฐœ ๋‹ด๋Š” ์ž…๋ ฅ์นธ ์ž์ฒด
placeholder์•„์ง ์•„๋ฌด๊ฒƒ๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š์•˜์„ ๋•Œ ์นธ ์•ˆ์— ํ๋ฆฌ๊ฒŒ ๋ณด์ด๋Š” ์•ˆ๋‚ด ๋ฌธ๊ตฌ(์˜ˆ: "ํƒœ๊ทธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”")
maxChips๋‹ด์„ ์ˆ˜ ์žˆ๋Š” ์นฉ์˜ ์ตœ๋Œ€ ๊ฐœ์ˆ˜ ์ œํ•œ (์˜ˆ: ์ตœ๋Œ€ 10๊ฐœ)
enabled์ž…๋ ฅ์นธ์„ ์“ธ ์ˆ˜ ์žˆ๊ฒŒ ์ผค์ง€(true), ์ž ๊ฐ€์„œ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ๋‘˜์ง€(false) ์ •ํ•˜๋Š” ๊ฐ’
onSubmitted์‚ฌ์šฉ์ž๊ฐ€ Enter๋ฅผ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ฐ’์„ ๋„ฃ์œผ๋ ค ํ•  ๋•Œ ์•Œ๋ ค ์ฃผ๋Š” ์‹ ํ˜ธ โ€” ์—ฌ๊ธฐ์„œ ์•ฑ์ด ๊ฐ’์„ ๊ฒ€์‚ฌํ•˜๊ณ  ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•จ
onChanged์นฉ์ด X ๋ฒ„ํŠผ์œผ๋กœ ์ง€์›Œ์ ธ ๋ชฉ๋ก์ด ๋ฐ”๋€Œ์—ˆ์„ ๋•Œ ์ƒˆ ๋ชฉ๋ก์„ ์•Œ๋ ค ์ฃผ๋Š” ์‹ ํ˜ธ
Flutter / Web(Jaspr)๊ฐ๊ฐ ์•ฑ ํ™”๋ฉด๊ณผ ์›น ํ™”๋ฉด์„ ๋งŒ๋“œ๋Š” ๊ธฐ์ˆ . ์ด ๊ธฐ๋Šฅ์€ ์–‘์ชฝ ๋ชจ๋‘์—์„œ ๋™์ž‘ํ•จ
style(์Šคํƒ€์ผ)์ž…๋ ฅ์นธ๊ณผ ์นฉ์˜ ์ƒ‰์ƒยท๋ชจ์„œ๋ฆฌ ๋‘ฅ๊ธ€๊ธฐยทํฌ๊ธฐ ๋“ฑ ๊ฒ‰๋ชจ์Šต ์„ค์ •

Overview#

ChipInput is a multi-value text input where users type text and press Enter to add chips; each chip carries a remove button. The chip list is List<String> โ€” the API contract (CoreChipInputContract) is identical on Flutter and Web.

Two behavioral facts drive every usage:

  1. Adding is app-side. Pressing Enter with non-empty (trimmed) text fires onSubmitted(text) and clears the entry field โ€” the widget never appends to chips itself. Your handler validates and appends.
  2. Removing is widget-side. Clicking a chip's X fires onChanged(nextList) with the chip already removed โ€” assign it back to state.

Chrome and dimensional overrides flow through the single chipInputStyle slot (CoreChipInputStyle). There is no variant, size, or shape enum on this component.

Merge chain:

design system default
  โ†’ CoreChipInputTheme.style      // ํ”„๋กœ์ ํŠธ ๊ณตํ†ต
  โ†’ parent component slot override
  โ†’ widget.chipInputStyle         // ์ธ์Šคํ„ด์Šค๋ณ„

Flutter (coui_flutter)#

Import#

import 'package:coui_flutter/coui_flutter.dart';

The barrel is the only supported import โ€” never import src/ paths.

Basic usage#

ChipInput(
  chips: tags,
  placeholder: 'Add a tag...',
  onSubmitted: (text) => setState(() => tags = [...tags, text]),
  onChanged: (chips) => setState(() => tags = chips),
)

onSubmitted receives the trimmed text (empty submits are ignored) and the field auto-clears and refocuses. onChanged receives the next list after an X-button removal.

With max chips#

When chips.length reaches maxChips, the inline entry field is not rendered โ€” removing a chip brings it back.

ChipInput(
  chips: selectedSkills,
  maxChips: 5,
  onSubmitted: (text) => setState(() => selectedSkills = [...selectedSkills, text]),
  onChanged: (chips) => setState(() => selectedSkills = chips),
)

Disabled state#

enabled: false makes the entry field read-only, switches the mouse cursor to basic, and hides every chip's remove button.

ChipInput(
  chips: readOnlyTags,
  enabled: false,
)

Validation before adding#

ChipInput(
  chips: emails,
  onSubmitted: (text) {
    if (!isValidEmail(text)) return; // ignore invalid โ€” field still clears
    setState(() => emails = [...emails, text]);
  },
  onChanged: (chips) => setState(() => emails = chips),
)

Style override#

ChipInput(
  chips: tags,
  onSubmitted: (text) => setState(() => tags = [...tags, text]),
  onChanged: (chips) => setState(() => tags = chips),
  chipInputStyle: const CoreChipInputStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
    chipBackgroundColor: CoreColor.token(CoreColors.primary),
    chipTextStyle: CoreTextStyle(
      color: CoreColor.token(CoreColors.onPrimary),
    ),
  ),
)

Flutter parameters#

ParameterTypeDefaultDescription
chips List<String> const [] Current chip list (controlled)
placeholder String? null Contract-parity field โ€” not rendered on Flutter (the inline editable region has no placeholder support); shown on Web only
enabled bool true Whether the input is interactive; false also hides remove buttons
maxChips int? null Maximum chips; null = unlimited; at the cap the entry field disappears
onChanged ValueChanged<List<String>>? null Fired with the next list when a chip is removed via its X button
onSubmitted ValueChanged<String>? null Fired on Enter with non-empty trimmed text; append the chip here
chipInputStyle CoreChipInputStyle? null Per-instance chrome/dimensional style override

Rendering details: chips and the entry field lay out in a Wrap; the focus ring is a FocusOutline following the container radius; each remove affordance is a plain-variant Button hosting an Icon(LucideIcons.x); the entry field keeps a fixed 80px minimum width floor (not a token).


Web / Jaspr (coui_web)#

Import#

import 'package:coui_web/coui_web.dart';

Basic usage#

ChipInput(
  chips: tags,
  placeholder: 'Add a tag...',
  onSubmitted: (text) => setState(() => tags = [...tags, text]),
  onChanged: (chips) => setState(() => tags = chips),
)

Same add/remove split as Flutter: Enter โ†’ onSubmitted (input value trimmed, field cleared), chip X โ†’ onChanged with the next list.

With max chips and DOM id#

ChipInput(
  id: 'skill-input',
  chips: selectedSkills,
  placeholder: 'Add a skill...',
  maxChips: 5,
  onSubmitted: (text) => setState(() => selectedSkills = [...selectedSkills, text]),
  onChanged: (chips) => setState(() => selectedSkills = chips),
)

Web-only constructor extras#

ParameterTypeDescription
id String? DOM id on the root <div>
classes String? Extra classes appended after the resolved class string
css Styles? Extra inline styles combined after the resolved inline styles
attributes Map<String, String>? Extra DOM attributes on the root
eventHandlers Map<String, List<UiEventHandler>>? Extra DOM event handlers

The component also provides copyWith(...). All shared parameters (chips, placeholder, enabled, maxChips, onChanged, onSubmitted, chipInputStyle) are identical to Flutter.

Web callback type#

On Web, callbacks use CoreValueChanged<T> instead of Flutter's ValueChanged<T> โ€” the handler bodies are written identically:

// Web
onChanged: (List<String> chips) { ... }   // CoreValueChanged<List<String>>
onSubmitted: (String text) { ... }        // CoreValueChanged<String>

DOM / accessibility#

< div role= " group "   aria-label= " Chip input " >     // flex-wrap container, focus-within ring
   < span > โ€ฆchip labelโ€ฆ < /span >                     // per chip; X button gets aria-label= " Remove {label} " 
   < input type= " text "   placeholder= " โ€ฆ " >           // rendered only while below maxChips
 < /div >

Disabled state emits the disabled attribute on the <input> plus cursor-not-allowed opacity-50 on the root, and remove buttons are not rendered.


Variant / Size#

This component has no semantic enums โ€” the full set is empty by design:

AxisEnumValues
variantโ€” (none)n/a
sizeโ€” (none)n/a
shapeโ€” (none)n/a

All appearance control goes through the single style slot chipInputStyle: CoreChipInputStyle.


Style reference โ€” CoreChipInputStyle#

All 17 constructor fields are optional; pass only what you override. merge and copyWith are provided; the design-system defaults are exposed as CoreChipInputStyle.default* statics.

Container fields#

FieldTypeDefault (static)
backgroundColor CoreColor? CoreColors.surface
borderColor CoreColor? CoreColors.outlineVariant
focusRingColor CoreColor? CoreColors.primary
borderWidth double? CoreStrokeWidth.stroke1
borderRadius CoreBorderRadius? CoreBorderRadius.all(CoreRadius.radius4)
minHeightdouble?CoreSize.size40
padding CoreEdgeInsets? symmetric(horizontal: CoreSpace.space12, vertical: CoreSpace.space8)
chipSpacingdouble?CoreSpace.space6

Chip badge fields#

FieldTypeDefault (static)
chipBackgroundColor CoreColor? CoreColors.secondary
chipTextStyle CoreTextStyle? labelSmall role token + medium weight + CoreColors.onSecondary
chipBorderRadius CoreBorderRadius? CoreBorderRadius.all(CoreRadius.radius9999) (pill)
chipHeightdouble?CoreSpace.space24
chipPadding CoreEdgeInsets? symmetric(horizontal: CoreSpace.space8, vertical: CoreSpace.space2)
chipCloseIconStyle CoreIconStyle? CoreIconStyle(size: CoreSize.size12)

Entry field + nested slots#

FieldTypeDefault (static)
inputTextStyle CoreTextStyle? bodySmall role token + CoreColors.onSurface
removeButtonStyle CoreButtonStyle? CoreButtonStyle(hoverForegroundColor: error token, padding: zero)
chipStyleCoreChipStyle?null

Text colors are carried inside the CoreTextStyle slots (chipTextStyle.color / inputTextStyle.color) โ€” there are no flat text-color fields.

Nested removeButtonStyle slot#

The chip remove affordance is a real plain-variant Button; removeButtonStyle is recursively merged onto the default and raw-forwarded to it. The remove-hover color lives here:

chipInputStyle: const CoreChipInputStyle(
  removeButtonStyle: CoreButtonStyle(
    hoverForegroundColor: CoreColor.token(CoreColors.errorContainer),
  ),
),

Nested chipStyle slot#

chipStyle: CoreChipStyle(...) is a convenience slot. Its matching sub-fields โ€” backgroundColor, labelStyle, borderRadius, padding, closeIconStyle โ€” take precedence over the corresponding flat chipXxx fields. Sub-fields without a CoreChipStyle counterpart (chipHeight) always flow through the flat fields.

chipInputStyle: const CoreChipInputStyle(
  chipStyle: CoreChipStyle(
    backgroundColor: CoreColor.token(CoreColors.primary),
    borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
  ),
),

Fixed (non-overridable) chrome#

These route through default* statics only โ€” there is no constructor field for them:

  • defaultCursorBackgroundColor (outline token) โ€” Flutter-only iOS-style background cursor of the inline editable region.
  • defaultPlaceholderColor (onSurfaceVariant token) โ€” Web-only placeholder: text class (Flutter renders no placeholder).
  • Entry-field minimum width: 80px usability floor (both platforms, not a token).

Project-wide theme#

CoreChipInputTheme has a single slot โ€” style. Register it on the app theme's coreComponentTheme.chipInput slot (CoreComponentTheme); both platform resolvers read theme.coreComponentTheme?.chipInput.

CoreChipInputTheme(
  style: CoreChipInputStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
  ),
)

Common patterns#

Tag input (unlimited, dedupe)#

ChipInput(
  chips: tags,
  placeholder: 'Enter a tag and press Enter',
  onSubmitted: (text) {
    if (tags.contains(text)) return;
    setState(() => tags = [...tags, text]);
  },
  onChanged: (chips) => setState(() => tags = chips),
)

Email recipients (max 10, validated)#

ChipInput(
  chips: recipients,
  placeholder: 'Add email...',
  maxChips: 10,
  onSubmitted: (text) {
    final isEmail = RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(text);
    if (!isEmail) return;
    setState(() => recipients = [...recipients, text]);
  },
  onChanged: (chips) => setState(() => recipients = chips),
)

Flutter โ†” Web Differences#

AspectFlutterWeb (Jaspr)
Widget base StatefulWidget , CoreChipInputContract<ValueChanged<List<String>>?, ValueChanged<String>?> UiComponent , CoreChipInputContract<CoreValueChanged<List<String>>?, CoreValueChanged<String>?>
Callback type ValueChanged<T> CoreValueChanged<T>
Extra ctor params โ€” id , classes , css , attributes , eventHandlers ; also copyWith(...)
placeholder accepted but not rendered (contract parity only) rendered as the <input> placeholder attribute
Rendering Wrap of chip Containers + inline editable region <div role="group"> with <span> chips + <input type="text">
Focus ring FocusOutline widget tracking focus state focus-within:ring-* Tailwind classes
After submit field clears and refocuses field clears (focus stays in the input naturally)
Disabled read-only field, basic cursor, remove buttons hidden disabled attr, cursor-not-allowed opacity-50, remove buttons hidden
A11y focus outline + widget semantics role="group" , aria-label="Chip input" , per-chip aria-label="Remove {label}"

Shared on both platforms: List<String> chip model, Enter-to-submit with trim + empty-ignore, maxChips hides the entry field, remove button is a plain-variant Button with Icon(LucideIcons.x), single chipInputStyle slot with identical resolution order.


v0.127 Migration (Legacy)#

v0.127 is a de-prefix hard break: the widget class was renamed and no Co-prefixed alias remains. Core* contract names (CoreChipInputStyle, CoreChipInputTheme, CoreChipInputContract, CoreChipStyle, CoreButtonStyle) are unchanged.

โŒ Legacy (pre-0.127, removed)โœ… v0.127
โŒ CoChipInput(chips: โ€ฆ)ChipInput(chips: โ€ฆ)
โŒ CoLucideIcons.x (close glyph)LucideIcons.x
โŒ CoreChipInputStyle(chipRemoveHoverColor: โ€ฆ) flat field CoreChipInputStyle(removeButtonStyle: CoreButtonStyle(hoverForegroundColor: โ€ฆ))

Additional migration notes:

  • โŒ Older docs suggested onChanged also fires when a chip is added โ€” in v0.127 the widget only fires onChanged on removal; adding is always your onSubmitted handler's job.
  • โŒ Pre-0.97 shadcn-era generic classes (ChipInput<T>, ControlledChipInput<T>, ChipInputController<T>, ChipWidgetBuilder<T>) are long gone โ€” the v0.127 ChipInput is non-generic with a List<String> model and no controller.
  • Import stays the single barrel (package:coui_flutter/coui_flutter.dart / package:coui_web/coui_web.dart).