문서 목차
이 페이지에서목차

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; the cc-coui plugin'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 on package:coui_flutter directly.


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, ButtonDensity do 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 removed ButtonStyle.primary().copyWith(...). There is no separate ButtonSize/ButtonDensity scale — size is CoreComponentSize (xs/sm/md/lg/xl) and dimensional tweaks (padding, height, etc.) flow through buttonStyle.


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 words Alert uses)
  • 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 / CoBadge do not exist, and CoreBadgeVariant no longer carries colour — the old variant: .destructive / .secondary / .primary are tone: 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 Material Icons) 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 in IconResolver and drawn with Icon (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, CoRadixIcons no longer compile (v0.127 de-prefix). HeroIcon (the standalone heroicons package 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) => ...,
)

TextField overrides go through textFieldStyle: CoreTextFieldStyle(...); size via size: .md (CoreComponentSize). CoreInputType: text, password, email, number, tel, url, search. See coui-text-field for 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);
APIStatus
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 에서 바뀌었다 — 본문이 한 단계 작아지고(bodyMedium 16 → 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) · Material CircularProgressIndicator · 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.

SlotPurpose
surfaceBase background
surfaceContainerLow · surfaceContainer · surfaceContainerHigh One step darker each — header · section · input
onSurfaceBody text
onSurfaceVariantSecondary text
outlineVariantSoft border · divider
outlineClear border

base100/200/300 · baseContent no 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

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기