CoUI Flutter Quick Reference (bare-widget standard)
All signatures below are checked against the coui release named in
기준: coui_flutter 0.163 · coui_core 0.162 (
coui.version.json의coui_skills_basis, v2). v2 에서 생긴 API 는 "(v2부터)" 로 표시한다. 앱이 참조하는 coui 버전을 올리는 절차는 cc-coui-upgrade 의coui-app-upgrade스킬. 💡 See 프로젝트 루트의CLAUDE.md(AGENTS.md) for the full guide. This document is a quick reference; thecc-couiplugin's per-component skills (coui-button,coui-text-field,coui-badge,coui-gap, …) are the source of truth — check there when in doubt.
Authoritative Source#
All signatures below are checked against the coui release named in
coui_skills_basis — coui_flutter components and the coui_core
contracts
(enums · styles). Import everything through the core barrel:
// ✅ CORRECT: single barrel import
import 'package:core/core.dart';
// ❌ WRONG: never import coui_flutter directly in feature/app code
import 'package:coui_flutter/coui_flutter.dart';
CoUI widgets are re-exported through
package:core/core.dart. Feature and app code must never depend onpackage:coui_flutterdirectly.
Components are bare <X> with variant: / size: enums#
CoUI follows a single unified widget per component (Button, Badge,
TextField, Icon, Gap) — as of v0.127 there is
no Co prefix on any
widget class; Co<X> names were removed in a hard break and no longer compile
(no aliases). There are no named constructors like Button.primary()
/
PrimaryBadge / ButtonStyle.ghost(). Visual style is selected via the
variant: (and size:) enum parameters; chrome overrides flow through the
single style object (buttonStyle: / badgeStyle: /
textFieldStyle: / iconStyle:).
| Concept | ✅ v0.127 standard | ❌ Removed / never existed |
|---|---|---|
| Button | Button(variant: ..., size: ...) |
CoButton
,
Button.primary()
,
Button.ghost()
,
ButtonStyle.ghost()
|
| Badge | Badge(variant: ...) |
CoBadge, PrimaryBadge, DestructiveBadge |
| Gap | const Gap.space16() / Gap(gapStyle: CoreGapStyle(size: 16)) |
CoGap
,
Gap.s4()
,
Gap(Insets.medium)
,
Spacing.s4
,
Insets.*
,
Gap(size: CoreSpace.spaceN)
(v0.92 bare-
size
form)
|
| Icon | Icon(LucideIcons.x) |
CoIcon, HeroIcon(HeroIcons.x) |
| TextField (one-line input, label/description/errorText/prefix/suffix) |
TextField(type: CoreInputType.x, label: ..., prefix: ..., features: [CoreInputFeature.clear()])
|
Input
(removed in 0.127.16),
CoTextField
,
FormTextField
, bare
InputFeature.leading(...)
/
.trailing(...)
(leading/trailing icons go through
prefix
/
suffix
directly;
CoreInputFeature
only has
.clear()
/
.passwordToggle()
)
|
| Text style | Text('…').titleMedium (role chain) |
context.textStyles.*, Tailwind-size chains (Text().lg.bold) |
Buttons — Button#
class Button extends StatefulWidget {
const Button({
required this.child,
super.key,
this.variant = CoreButtonVariant.primary,
this.size = CoreComponentSize.md,
this.shape = CoreButtonShape.rectangle,
this.leading,
this.trailing,
this.onPressed,
this.enabled,
this.buttonStyle,
this.expanded = false,
// ... interaction callbacks
});
}
CoreButtonVariant#
primary · secondary · outline · ghost · link
· text · plain ·
destructive · card · menu · menubar
· fixed
CoreComponentSize (shared by all components)#
xs · sm · md (default) · lg · xl
ButtonSize.medium,ButtonSize.normal,ButtonDensitydo not exist.
Usage#
// Primary action — uses defaults (variant: primary, size: md)
Button(
expanded: true,
enabled: state.isValid,
onPressed: () => context.read<MyBloc>().add(const MyEvent.submitted()),
child: const Text('저장'),
)
// Secondary / cancel — dot-shorthand on the enum
Button(
variant: .ghost,
onPressed: () => context.pop(),
child: const Text('취소'),
)
// Destructive
Button(
variant: .destructive,
onPressed: onDelete,
child: const Text('삭제'),
)
// Sized + leading icon
Button(
variant: .outline,
size: .sm,
leading: const Icon(LucideIcons.pencil),
onPressed: onEdit,
child: const Text('편집'),
)
// Icon-only via shape
Button(
variant: .ghost,
shape: .square,
onPressed: onMore,
child: const Icon(LucideIcons.ellipsisVertical),
)
Chrome / dimensional overrides go through
buttonStyle: CoreButtonStyle(...), not through removedButtonStyle.primary().copyWith(...). There is no separateButtonSize/ButtonDensityscale — size isCoreComponentSize(xs/sm/md/lg/xl) and dimensional tweaks (padding, height, etc.) flow throughbuttonStyle.
Badge — Badge#
class Badge extends StatefulWidget {
const Badge({
this.tone = CoreBadgeTone.defaultTone, // neutral — what the colour means
this.variant = CoreBadgeVariant.defaultVariant, // tonal — how loud it is
this.child,
super.key,
this.form = CoreBadgeForm.defaultForm,
this.size = CoreBadgeContract.defaultSize, // CoreComponentSize.md
this.showDot = CoreBadgeContract.defaultShowDot,
this.leading,
this.trailing,
this.onPressed,
this.badgeStyle,
});
}
Three axes — tone · variant · form#
-
tone: CoreBadgeTone— the meaning:neutral(default) ·primary·info·success·warning·destructive(the same wordsAlertuses) -
variant: CoreBadgeVariant— the strength:filled·tonal(default) ·outlined -
form: CoreBadgeForm— what it holds:label(default) ·count·dot
Any tone can be worn at any strength — "a quiet red label" is tone: .destructive + variant: .tonal.
Usage#
// Positive / active status
const Badge(tone: .success, child: Text('판매중'))
// Negative status
const Badge(tone: .destructive, child: Text('판매중지'))
// With a leading icon, outlined
Badge(
tone: .primary,
variant: .outlined,
leading: const Icon(LucideIcons.eye),
child: const Text('미리보기'),
)
// Conditional
book.isActive
? const Badge(tone: .success, child: Text('판매중'))
: const Badge(tone: .destructive, child: Text('판매중지'))
PrimaryBadge/DestructiveBadge/CoBadgedo not exist, andCoreBadgeVariantno longer carries colour — the oldvariant: .destructive/.secondary/.primaryaretone:values now (secondary→neutral,outline→variant: .outlined).
Icons — Icon#
class Icon extends StatefulWidget {
const Icon(this.icon, {super.key, this.iconStyle});
}
Icons come from the Lucide set CoUI re-exports — use LucideIcons (bare names, no
Co prefix in v0.127) for every icon on a screen.
const Icon(LucideIcons.trash)
const Icon(LucideIcons.chevronDown)
// Size / color override
Icon(
LucideIcons.circleAlert,
iconStyle: CoreIconStyle(size: 16, color: CoreColor.token(CoreColors.error)),
)
-
One set. Do not mix
HeroIcons·RadixIcons·BootstrapIcons(or MaterialIcons) into a screen. CoUI sets the stroke weight from the size (CoreIconStroke: 1.25 up to size 20, then size ÷ 16) and that rule only rewrites Lucide glyphs, so a glyph from another set reads heavier or lighter than the Lucide icon beside it. The app never sets a stroke width. -
A glyph Lucide lacks — an SVG asset tinted with a token colour
(
colorFilter: ColorFilter.mode(context.colorScheme.onSurfaceVariant.toValue(), BlendMode.srcIn)), or registered inIconResolverand drawn withIcon(cc-coui:coui-icon). A hex left in the SVG stays the same in dark mode. Multi-colour marks (logos) are not tinted.
CoIcon,CoLucideIcons,CoRadixIconsno longer compile (v0.127 de-prefix).HeroIcon(the standaloneheroiconspackage widget) is not used.
Text Input — TextField#
TextField is the single-line text-entry widget — type, placeholder
(a widget), value, onChanged, validation/length/range attributes, plus
label / description / errorText / prefix / suffix slots. The bare Input
was
removed in 0.127.16. There is no FormTextField, and there is
no bare
InputFeature leading/trailing widget-building API — the platform-neutral
data model is CoreInputFeature, and it only has two factories: .clear()
and
.passwordToggle() (arbitrary leading/trailing icons go through TextField's
own prefix/suffix parameters, not through a feature).
// One-line input — no label
TextField(
type: CoreInputType.email,
placeholder: const Text('이메일을 입력하세요'),
value: state.email,
onChanged: (value) =>
context.read<MyBloc>().add(MyEvent.emailChanged(value)),
)
// Error state
TextField(
type: CoreInputType.email,
placeholder: const Text('이메일'),
errorText: state.emailError,
onChanged: (value) => ...,
)
// TextField — labelled field with label / prefix / errorText / built-in features
TextField(
label: '이메일',
placeholder: const Text('이메일을 입력하세요'),
controller: controller,
keyboardType: TextInputType.emailAddress,
prefix: const Icon(LucideIcons.mail),
errorText: state.emailError,
onChanged: (value) => ...,
)
// TextField — password with the built-in visibility-toggle feature
TextField(
label: '비밀번호',
obscureText: true,
features: const [CoreInputFeature.passwordToggle()],
onChanged: (value) => ...,
)
TextFieldoverrides go throughtextFieldStyle: CoreTextFieldStyle(...); size viasize: .md(CoreComponentSize).CoreInputType:text,password,number,tel,url,search. Seecoui-text-fieldfor the complete parameter set.
Typography — Text role chains#
Text picks a role through the Text chain — size, weight, line height and
tracking come as one set. Chain a weight or a colour on top when it differs.
Text('제목').titleMedium
Text('본문').bodyMedium
Text('강조 라벨').labelLarge.semiBold
Text('보조 설명').bodySmall.onSurfaceVariant
When a TextStyle is required (a third-party widget, text you paint yourself),
read the same role from the theme:
final style = context.typography.titleMedium.toValue(theme: context.theme);
| API | Status |
|---|---|
Text('…').<role> | ✅ Default |
context.typography.<role>.toValue(theme: context.theme) |
✅ When a TextStyle is needed |
context.textStyles.* | ❌ Not in CoUI — an old app-template extension |
Text('...').lg.bold (Tailwind sizes) |
❌ Removed — .lg → .bodyLarge (see coui-text "제거된 체인") |
Roles (Figma text style = Text chain)#
| Figma 텍스트 스타일 | Text 체인 |
크기 | 굵기 | 행간 | 자간 | 서체 | v2 이전 |
|---|---|---|---|---|---|---|---|
Display/Large |
.displayLarge |
52 | 700 | 140% | −2% | 본문 | 같은 이름 |
Display/Medium |
.displayMedium |
48 | 700 | 140% | −2% | 본문 | 같은 이름 |
Display/Small |
.displaySmall |
44 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Large |
.headlineLarge |
32 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Medium |
.headlineMedium |
28 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Small |
.headlineSmall |
24 | 700 | 140% | −2% | 본문 | 같은 이름 |
Title/Large |
.titleLarge |
20 | 700 | 140% | −2% | 본문 | 같은 이름 |
Title/Medium |
.titleMedium |
18 | 700 | 140% | −1% | 본문 | 같은 이름 |
Title/Small |
.titleSmall |
16 | 700 | 140% | −1% | 본문 | 같은 이름 |
Body/Large |
.bodyLarge |
15 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/Medium |
.bodyMedium |
14 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/Small |
.bodySmall |
13 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/XS |
.bodyXs (v2부터) |
12 | 400 | 150% | −1% | 본문 | .bodySmall |
Label/XL2 |
.labelXl2 |
16 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/XL |
.labelXl |
15 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Large |
.labelLarge |
14 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Medium |
.labelMedium |
13 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Small |
.labelSmall |
12 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/XS |
.labelXs (v2부터) |
11 | 500 | 140% | −1% | 본문 | .labelSmall |
Code/XS |
.codeXs (v2부터) |
11 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
Code/SM |
.codeSm (v2부터) |
12 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
Code/MD |
.codeMd (v2부터) |
13 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
v2 이전— v2(coui_flutter 0.163 · coui_core 0.162) 전 릴리스에서 같은 자리에 쓰는 체인. 이름이 같아도 값은 대부분 v2 에서 바뀌었다 — 본문이 한 단계 작아지고(bodyMedium16 → 14), 헤드라인이 700 이 되고, 타이틀 · 라벨 행간이 140% 가 됐다. 그 전 릴리스의 값은 그 릴리스의CoreTypography.coui가 정한다 — 이 표의 숫자를 옮겨 쓰지 않는다.
Spacing — Gap (token constructors / gapStyle)#
Prefer the Gap.spaceN() token constructors. For non-token sizes, a cross-axis
extent, or a fill color use the single gapStyle: CoreGapStyle(...) slot.
Insets, Spacing, and older CoGap forms are removed/legacy. As of v0.127
there is no Co prefix — the widget is bare Gap; the bare
Gap(size: CoreSpace.spaceN) constructor-level size: form (pre-v0.127) is
also gone — use the token constructor or gapStyle: instead.
class Gap extends StatelessWidget {
const Gap({this.gapStyle, super.key});
// plus const Gap.spaceN() per CoreSpace.spaceN, and Gap.expand(...)
}
// ✅ v0.127 standard — token constructors
const Gap.space4() // 4px
const Gap.space8() // 8px
const Gap.space16() // 16px
const Gap.space24() // 24px
// ✅ Non-token size / cross extent / fill color via gapStyle
const Gap(gapStyle: CoreGapStyle(size: 18))
// EdgeInsets / padding also use CoreSpace tokens
Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: child,
)
CoreSpace tokens (double, logical px)#
space0 · space2 · space4 · space6 · space8
· space10 · space12 ·
space14 · space16 · space20 · space24
· space28 · space32 … (2px
steps to 16, then 4px steps). The token name is the pixel value. Each value
has a matching Gap.spaceN() constructor.
| ❌ Removed / legacy | ✅ Replacement (v0.127.15) |
|---|---|
❌ CoGap (any form) | Gap |
const Gap.s1() (4px) | const Gap.space4() |
const Gap.s2() (8px) | const Gap.space8() |
const Gap.s4() (16px) | const Gap.space16() |
Gap(Insets.medium) | const Gap.space16() |
const Gap(size: CoreSpace.space16) (pre-v0.127 bare-size form) |
const Gap.space16()
or
const Gap(gapStyle: CoreGapStyle(size: CoreSpace.space16))
|
❌ Spacing.s4, Insets.medium |
CoreSpace.space16 |
Loading / Skeleton Patterns#
The caller places Skeletons shaped like the final layout and wraps one
area's skeletons in a LoadingRegion, which announces "loading" once.
Loading is the in-progress spinner; an area with nothing to show (or a
failure) is an EmptyState.
// Where data will land — skeletons of the same shape
state.isLoading
? LoadingRegion(
child: Column(
crossAxisAlignment: .start,
children: [
Skeleton(width: 200, height: 20),
Skeleton(width: 150, height: 20),
],
),
)
: OrderList(orders: state.orders)
// In-progress spinner
const Loading()
// Empty or failed — with a way back
EmptyState(
tone: CoreEmptyStateTone.failure,
title: const Text('불러오지 못했다'),
action: Button(onPressed: onRetry, child: const Text('다시 시도')),
)
❌
.skeletonizer()·.asSkeleton(deprecated in 0.156) · MaterialCircularProgressIndicator·EmptyStateWidget.
Complete Page Example#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
class MyPage extends HookWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => MyBloc(),
child: BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
return Scaffold(
headers: [
AppBar(title: const Text('페이지 제목')), // AppBar applies the title role
],
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: CoreSpace.space16,
),
child: state.isLoading
? LoadingRegion(
child: Column(
children: [Skeleton(height: 40), Skeleton(height: 40)],
),
)
: Column(
crossAxisAlignment: .start,
children: [
const Gap.space16(),
TextField(
type: CoreInputType.text,
placeholder: const Text('이름을 입력하세요'),
onChanged: (value) => context.read<MyBloc>().add(
MyEvent.nameChanged(value),
),
),
const Gap.space16(),
Button(
expanded: true,
enabled: state.isValid,
onPressed: () => context.read<MyBloc>().add(
const MyEvent.submitted(),
),
child: const Text('저장'),
),
],
),
),
);
},
),
);
}
}
Color Scheme#
Colours are named — ColorScheme slots, never an app's own palette constants
or HEX values. Brightness, contrast and brand changes follow the slot.
| Slot | Purpose |
|---|---|
surface | Base background |
surfaceContainerLow · surfaceContainer · surfaceContainerHigh |
One step darker each — header · section · input |
onSurface | Body text |
onSurfaceVariant | Secondary text |
outlineVariant | Soft border · divider |
outline | Clear border |
base100/200/300·baseContentno longer exist —surface·surfaceContainer·surfaceContainerHigh·onSurface.
// A widget's colour goes through that widget's style slot
TextField(
textFieldStyle: const CoreTextFieldStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
borderColor: CoreColor.token(CoreColors.outlineVariant),
),
)
// Dividers are Divider, surfaces are Card — don't paint the box yourself
const Divider(dividerStyle: CoreDividerStyle(color: CoreColor.token(CoreColors.outline)))
// Text or icon colour you draw yourself comes from context.colorScheme
final muted = context.colorScheme.onSurfaceVariant.toValue();
Reference Documents#
Refer to 프로젝트 루트의 CLAUDE.md(AGENTS.md) for the following:
- Color rules (an app's rules on top of the ColorScheme)
- Dot Shorthand rules (Dart 3.10+) — note
variant: .ghost,size: .sm - BLoC Event/State patterns (sealed class)
- BLoC async handlers (isClosed check)
- Complete list of Context Extensions