Button#
사용자 인터랙션을 위한 기본 버튼 컴포넌트입니다. Primary·secondary·outline·ghost·link·plain·card·destructive 변형을 지원합니다.
Live Preview#
class ButtonPrimaryExample extends StatefulComponent {
const ButtonPrimaryExample({super.key});
@override
State<ButtonPrimaryExample> createState() => _ButtonPrimaryExampleState();
}
class _ButtonPrimaryExampleState extends State<ButtonPrimaryExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
);
}
}
class ButtonPrimaryExample extends StatefulWidget {
const ButtonPrimaryExample({super.key});
@override
State<ButtonPrimaryExample> createState() => _ButtonPrimaryExampleState();
}
class _ButtonPrimaryExampleState extends State<ButtonPrimaryExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
);
}
}
class ButtonSecondaryExample extends StatefulComponent {
const ButtonSecondaryExample({super.key});
@override
State<ButtonSecondaryExample> createState() => _ButtonSecondaryExampleState();
}
class _ButtonSecondaryExampleState extends State<ButtonSecondaryExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.secondary,
onPressed: () {},
child: Text('Secondary').lineLimit(1).ellipsis,
);
}
}
class ButtonSecondaryExample extends StatefulWidget {
const ButtonSecondaryExample({super.key});
@override
State<ButtonSecondaryExample> createState() => _ButtonSecondaryExampleState();
}
class _ButtonSecondaryExampleState extends State<ButtonSecondaryExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.secondary,
onPressed: () {},
child: Text('Secondary').lineLimit(1).ellipsis,
);
}
}
class ButtonOutlineExample extends StatefulComponent {
const ButtonOutlineExample({super.key});
@override
State<ButtonOutlineExample> createState() => _ButtonOutlineExampleState();
}
class _ButtonOutlineExampleState extends State<ButtonOutlineExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Outline').lineLimit(1).ellipsis,
);
}
}
class ButtonOutlineExample extends StatefulWidget {
const ButtonOutlineExample({super.key});
@override
State<ButtonOutlineExample> createState() => _ButtonOutlineExampleState();
}
class _ButtonOutlineExampleState extends State<ButtonOutlineExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {},
child: Text('Outline').lineLimit(1).ellipsis,
);
}
}
class ButtonGhostExample extends StatefulComponent {
const ButtonGhostExample({super.key});
@override
State<ButtonGhostExample> createState() => _ButtonGhostExampleState();
}
class _ButtonGhostExampleState extends State<ButtonGhostExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.ghost,
onPressed: () {},
child: Text('Ghost').lineLimit(1).ellipsis,
);
}
}
class ButtonGhostExample extends StatefulWidget {
const ButtonGhostExample({super.key});
@override
State<ButtonGhostExample> createState() => _ButtonGhostExampleState();
}
class _ButtonGhostExampleState extends State<ButtonGhostExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.ghost,
onPressed: () {},
child: Text('Ghost').lineLimit(1).ellipsis,
);
}
}
class ButtonLinkExample extends StatefulComponent {
const ButtonLinkExample({super.key});
@override
State<ButtonLinkExample> createState() => _ButtonLinkExampleState();
}
class _ButtonLinkExampleState extends State<ButtonLinkExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.link,
onPressed: () {},
child: Text('Link').lineLimit(1).ellipsis,
);
}
}
class ButtonLinkExample extends StatefulWidget {
const ButtonLinkExample({super.key});
@override
State<ButtonLinkExample> createState() => _ButtonLinkExampleState();
}
class _ButtonLinkExampleState extends State<ButtonLinkExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.link,
onPressed: () {},
child: Text('Link').lineLimit(1).ellipsis,
);
}
}
class ButtonPlainExample extends StatefulComponent {
const ButtonPlainExample({super.key});
@override
State<ButtonPlainExample> createState() => _ButtonPlainExampleState();
}
class _ButtonPlainExampleState extends State<ButtonPlainExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.plain,
onPressed: () {},
child: Text('Plain').lineLimit(1).ellipsis,
);
}
}
class ButtonPlainExample extends StatefulWidget {
const ButtonPlainExample({super.key});
@override
State<ButtonPlainExample> createState() => _ButtonPlainExampleState();
}
class _ButtonPlainExampleState extends State<ButtonPlainExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.plain,
onPressed: () {},
child: Text('Plain').lineLimit(1).ellipsis,
);
}
}
class ButtonCardExample extends StatefulComponent {
const ButtonCardExample({super.key});
@override
State<ButtonCardExample> createState() => _ButtonCardExampleState();
}
class _ButtonCardExampleState extends State<ButtonCardExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.card,
onPressed: () {},
child: Text('Card').lineLimit(1).ellipsis,
);
}
}
class ButtonCardExample extends StatefulWidget {
const ButtonCardExample({super.key});
@override
State<ButtonCardExample> createState() => _ButtonCardExampleState();
}
class _ButtonCardExampleState extends State<ButtonCardExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.card,
onPressed: () {},
child: Text('Card').lineLimit(1).ellipsis,
);
}
}
class ButtonDestructiveExample extends StatefulComponent {
const ButtonDestructiveExample({super.key});
@override
State<ButtonDestructiveExample> createState() => _ButtonDestructiveExampleState();
}
class _ButtonDestructiveExampleState extends State<ButtonDestructiveExample> {
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.destructive,
onPressed: () {},
child: Text('Destructive').lineLimit(1).ellipsis,
);
}
}
class ButtonDestructiveExample extends StatefulWidget {
const ButtonDestructiveExample({super.key});
@override
State<ButtonDestructiveExample> createState() => _ButtonDestructiveExampleState();
}
class _ButtonDestructiveExampleState extends State<ButtonDestructiveExample> {
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.destructive,
onPressed: () {},
child: Text('Destructive').lineLimit(1).ellipsis,
);
}
}
class ButtonCastMotionExample extends StatefulComponent {
const ButtonCastMotionExample({super.key});
@override
State<ButtonCastMotionExample> createState() =>
_ButtonCastMotionExampleState();
}
class _ButtonCastMotionExampleState extends State<ButtonCastMotionExample> {
@override
Component build(BuildContext context) {
return div(
[
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(
castMotion: CoreCastMotion.defaultMotion,
),
onPressed: () {},
child: Text('Default').lineLimit(1).ellipsis,
),
const Gap.space12(),
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(
castMotion: CoreCastMotion.noShadow,
),
onPressed: () {},
child: Text('No shadow').lineLimit(1).ellipsis,
),
const Gap.space12(),
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(castMotion: CoreCastMotion.reverse),
onPressed: () {},
child: Text('Reverse').lineLimit(1).ellipsis,
),
],
classes: 'flex flex-row items-center',
);
}
}
class ButtonCastMotionExample extends StatefulWidget {
const ButtonCastMotionExample({super.key});
@override
State<ButtonCastMotionExample> createState() =>
_ButtonCastMotionExampleState();
}
class _ButtonCastMotionExampleState extends State<ButtonCastMotionExample> {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(
castMotion: CoreCastMotion.defaultMotion,
),
onPressed: () {},
child: Text('Default').lineLimit(1).ellipsis,
),
const Gap.space12(),
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(
castMotion: CoreCastMotion.noShadow,
),
onPressed: () {},
child: Text('No shadow').lineLimit(1).ellipsis,
),
const Gap.space12(),
Button(
variant: CoreButtonVariant.primary,
buttonStyle: const CoreButtonStyle(castMotion: CoreCastMotion.reverse),
onPressed: () {},
child: Text('Reverse').lineLimit(1).ellipsis,
),
],
);
}
}
class ButtonChainExample extends StatelessComponent {
const ButtonChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Full control').lineLimit(1).ellipsis,
).withStyle(
const CoreButtonStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
class ButtonChainExample extends StatelessWidget {
const ButtonChainExample({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Full control').lineLimit(1).ellipsis,
).withStyle(
const CoreButtonStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 폼 제출, 저장, 삭제 등 명확한 액션을 트리거할 때
- 다이얼로그의 확인/취소 버튼이 필요할 때
- 네비게이션이 아닌 사용자 액션을 수행할 때
대신 다른 컴포넌트를 사용하세요:
Toggle: 켜기/끄기 상태를 전환할 때Link (Text): 페이지 이동이 목적일 때FAB: 화면의 주요 플로팅 액션일 때Menu: 여러 옵션 중 선택이 필요할 때
기본 사용법 (Basic Usage)#
// 기본 버튼 — variant 는 위젯 파라미터
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSubmit,
child: Text('제출'),
)
// 다른 변형
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
)
// 비활성화 (onPressed null)
Button(
variant: CoreButtonVariant.primary,
onPressed: null,
child: Text('비활성화'),
)
// 기본 버튼 — variant 는 위젯 파라미터
Button(
variant: CoreButtonVariant.primary,
onPressed: handleSubmit,
child: Text('제출'),
)
// 다른 변형
Button(
variant: CoreButtonVariant.outline,
onPressed: handleCancel,
child: Text('취소'),
)
// 비활성화
Button(
variant: CoreButtonVariant.primary,
enabled: false,
child: Text('비활성화'),
)
시맨틱 vs 스타일 — 분리 패턴#
Button 의 인스턴스 정체성을 결정하는 시맨틱 enum 은 위젯 파라미터로,
chrome / dimensional 미세 조정은 buttonStyle 슬롯으로 분리됩니다.
| 분류 | 어디로 | 예시 |
|---|---|---|
| 시맨틱 enum (정체성) | 위젯 파라미터 | variant, size, shape |
| chrome / dimensional (자유 override) | buttonStyle: CoreButtonStyle(...) |
width, padding, backgroundColor, labelStyle |
| 인터랙션 / 콜백 / 포커스 | 위젯 파라미터 | onPressed, enabled, focusNode |
같은 의미의 필드를 두 군데에서 받지 않습니다 — Material / shadcn / Radix / MUI / Card 모두 동일 패턴입니다.
Button(
variant: CoreButtonVariant.primary, // ← 시맨틱 (위젯 파라미터)
size: CoreComponentSize.lg, // ← 시맨틱 (위젯 파라미터)
onPressed: handleSubmit, // ← 콜백 (위젯 파라미터)
buttonStyle: CoreButtonStyle( // ← chrome 미세 override
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space24),
width: CoreSpace.space200,
labelStyle: CoreTextStyle(
fontWeight: CoreFontWeight.semiBold,
),
leadingIconStyle: CoreIconStyle(size: CoreIconSize.size18),
),
leading: Icon(LucideIcons.check),
child: Text('Submit'),
)
하위 시맨틱 변경 — asChild 패턴#
composite 컴포넌트 (Select, Dialog, Popover 등) 가 자기 trigger 의
시맨틱 (variant) 을 외부에서 조정해야 한다면, sub-component 의 Style 안에
packed 시키지 않고 위젯 자체를 슬롯으로 받습니다 (shadcn asChild
패턴).
// composite 의 sub-component variant 를 외부에서 결정
Select(
trigger: Button(variant: .outline, child: Text('Select…')),
options: [...],
)
CoreSelectStyle.triggerStyle: CoreButtonStyle? 같은 nested 슬롯은 chrome
미세 조정용 입니다 — variant 는 들어가지 않습니다.
머지 체인 (Merge Chain)#
buttonStyle 은 다음 체인을 거쳐 최종 값으로 해석됩니다 (오른쪽이 이김):
design system default for variant
→ CoreButtonTheme.style // 프로젝트 공통
→ CoreButtonTheme.variantStyles[widget.variant] // variant 별
→ 부모 컴포넌트 슬롯 오버라이드 // 예: CorePopoverStyle.triggerStyle
→ widget.buttonStyle // 인스턴스별
각 레이어는 CoreButtonStyle 의 어떤 필드든 부분적으로 채울 수 있습니다.
nested slot styles (labelStyle / leadingIconStyle
/ trailingIconStyle) 은
null 이 아닌 경우 재귀 머지 됩니다 — labelStyle.fontWeight
만 override 해도
labelStyle.fontSize / labelStyle.color 는 이전 레이어 값 유지.
테마 적용#
ThemeData.fromCore(
CoreThemePresets.light, // 베이스 코어 테마 (필수)
coreComponentTheme: CoreComponentTheme(
button: CoreButtonTheme(
style: CoreButtonStyle( // 모든 버튼 공통
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16),
animationDuration: Duration(milliseconds: CoreDuration.fast),
disableHoverEffect: false,
),
variantStyles: {
CoreButtonVariant.primary: CoreButtonStyle( // primary 만 더 큰 padding
padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space24),
labelStyle: CoreTextStyle(
fontWeight: CoreFontWeight.semiBold,
),
),
},
),
),
)
Props / Parameters#
파라미터가 많아 의미 그룹으로 나눠 적습니다. 이름·순서·기본값은 Flutter·Web 동일하며, 타입만 플랫폼 idiom 을 따릅니다.
콘텐츠 · 시맨틱 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget (Flutter) / Component (Web) |
필수 | 버튼 본문 (label) |
variant |
CoreButtonVariant |
primary |
시맨틱 변형 (
primary
·
secondary
·
outline
·
ghost
·
link
·
plain
·
destructive
·
card
·
menu
·
menubar
·
fixed
;
text
는 deprecated —
link
/
plain
중 용도에 맞는 쪽 사용)
|
size |
CoreComponentSize |
md |
크기 토큰 |
shape |
CoreButtonShape |
rectangle |
형태 (rectangle · square · circle) |
leading |
Widget? (Flutter) / Component? (Web) |
null |
텍스트 앞 아이콘/위젯 |
trailing |
Widget? (Flutter) / Component? (Web) |
null |
텍스트 뒤 아이콘/위젯 |
buttonStyle |
CoreButtonStyle? |
null |
chrome / dimensional / nested slot 묶음 (아래 표) |
상태 · 레이아웃 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
enabled |
bool? |
null |
활성화 여부. null 이면 onPressed != null 로 유도 |
expanded |
bool |
false |
부모 너비 채우기 |
alignment |
AlignmentGeometry? (Flutter) / CoreAlignment? (Web) |
null |
콘텐츠 정렬 |
marginAlignment |
AlignmentGeometry? (Flutter) / CoreAlignment? (Web) |
null |
마진 정렬 |
disableTransition |
bool |
false |
상태 전환 애니메이션 끄기 |
disableHoverEffect |
bool |
false |
hover 시각 효과 끄기 (buttonStyle.disableHoverEffect 와 OR) |
disableFocusOutline |
bool |
false |
포커스 아웃라인 끄기 |
주 콜백 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
onPressed |
VoidCallback? |
null |
클릭 핸들러. null 이면 비활성화 |
onHover |
ValueChanged<bool>? (Flutter) / void Function(bool)? (Web) |
null |
hover 진입/이탈 |
onFocus |
ValueChanged<bool>? (Flutter) / void Function(bool)? (Web) |
null |
포커스 획득/상실 |
제스처 콜백 파라미터#
포인터 위치를 넘기는 콜백은 Flutter 가 제스처 콜백 타입, Web 이 CorePointerCallback / CoreDragCallback
을 씁니다. 위치 없는 취소·완료 콜백은 양쪽 VoidCallback? 입니다.
| 속성 | 타입 (Flutter / Web) | 기본값 | 설명 |
|---|---|---|---|
onTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
주 포인터 누름 |
onTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
주 포인터 뗌 |
onTapCancel |
VoidCallback? |
null |
주 포인터 취소 |
onSecondaryTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
보조(오른쪽) 포인터 누름 |
onSecondaryTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
보조 포인터 뗌 |
onSecondaryTapCancel |
VoidCallback? |
null |
보조 포인터 취소 |
onTertiaryTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
3차(가운데) 포인터 누름 |
onTertiaryTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
3차 포인터 뗌 |
onTertiaryTapCancel |
VoidCallback? |
null |
3차 포인터 취소 |
onLongPressStart |
GestureLongPressStartCallback? / CorePointerCallback? |
null |
롱프레스 시작 |
onLongPressUp |
VoidCallback? |
null |
롱프레스 후 뗌 |
onLongPressMoveUpdate |
GestureLongPressMoveUpdateCallback? / CoreDragCallback? |
null |
롱프레스 중 이동 |
onLongPressEnd |
GestureLongPressEndCallback? / CorePointerCallback? |
null |
롱프레스 종료 |
onSecondaryLongPress |
VoidCallback? |
null |
보조 포인터 롱프레스 |
onTertiaryLongPress |
VoidCallback? |
null |
3차 포인터 롱프레스 |
프레임워크 인프라 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
enableFeedback |
bool? |
null |
햅틱 피드백 (Web 은 no-op) |
focusNode |
FocusNode? (Flutter) / CoreFocusNode? (Web) |
null |
포커스 노드. Web 은 브라우저 포커스가 그 역할을 대신함 |
statesController |
WidgetStatesController? (Flutter) / CoreStatesController? (Web) |
null |
외부 상태 컨트롤러 |
CoreButtonStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Background fill colour override. |
foregroundColor |
CoreColor? |
Foreground (label / icon) colour override. Nested [labelStyle] / [leadingIconStyle] / [trailingIconStyle] take precedence over this when set. |
borderColor |
CoreColor? |
Border stroke colour override. |
hoverBackgroundColor |
CoreColor? |
Hover background fill colour override.
null
→ the variant's
hoverBackground
. Mirrors [backgroundColor] for the hovered state so the hover chrome is per-instance overridable, not variant-locked.
|
hoverForegroundColor |
CoreColor? |
Hover foreground (label / icon) colour override.
null
→ the variant's
hoverForeground
(else idle foreground).
|
hoverBorderColor |
CoreColor? |
Hover border stroke colour override.
null
→ the variant's
hoverBorderColor
(else idle border colour). Hover
icon
colour is intentionally not a flat field here — icon chrome flows through the [leadingIconStyle] / [trailingIconStyle] (
CoreIconStyle
) slots, and the hovered icon falls through to [hoverForegroundColor] (mirrors how the idle icon follows [foregroundColor]).
|
borderWidth |
double? |
Border stroke width override (logical px, pre-scaling). |
borderRadius |
CoreBorderRadius? |
Border radius override. |
boxShadow |
List<CoreShadowLayer>? |
Drop-shadow / elevation override.
null
→ no shadow (buttons are flat by default); a non-null layer list paints an elevation behind the surface — used by composing components such as
Fab
that need a floating elevation on an otherwise plain button surface.
No defaultBoxShadow, deliberately.
Absence
is
the flat surface. Flutter hands the null straight to
BoxDecoration.boxShadow
, where null means paint nothing, and Web writes
box-shadow
only inside
if (merged.boxShadow != null)
. A non-null default freezes both branches the same way: every button in the kit would carry an elevation, and
Fab
would lose the one thing that distinguishes it from the plain surface it composes.
|
castMotion |
CoreCastMotion? |
What this button's cast does when the button is engaged. Sits beside [boxShadow] rather than on the widget because it is a statement about the surface, not about which button this is — the same reasoning that puts
CoreCardStyle.elevation
here.
variant
and
size
say what the control
is
; this says how its surface behaves, which is chrome. Null takes [defaultCastMotion].
|
dashedBorder |
bool? |
Whether to render a dashed border instead of a solid one. |
dashedBorderColor |
CoreColor? |
Dashed border stroke colour override. Falls back to [borderColor] when null. |
dashLength |
double? |
Dash segment length when [dashedBorder] is true (logical px).
null
→ [defaultDashLength].
|
dashSpacing |
double? |
Gap between dash segments (logical px). null → [defaultDashSpacing]. |
height |
double? |
Fixed height override (logical px). |
width |
double? |
Fixed width override (logical px).
No defaultWidth, deliberately.
null
is the "hug content" signal rather than a value nobody got round to choosing: Flutter wraps the button in a
SizedBox(width:)
only when the effective width is non-null (
expanded
is what supplies
double.infinity
there), and Web writes
width
only inside
if (merged.width != null)
. Any default pins every button to one fixed width and leaves
expanded
as the only route back to content-sized.
|
minWidth |
double? |
Minimum width constraint (logical px).
No defaultMinWidth, deliberately — and 0 is not a value to lift into one.
Flutter's
?? 0
is
BoxConstraints
' spelling of "no minimum" (that field cannot be null); Web omits the declaration. Those two agree, but
min-width: 0
does
not
agree with either: the root is
inline-flex
, so as a flex item it takes
min-width: auto
— the automatic content-based minimum — and emitting
0rem
in its place would let a button be squeezed narrower than its own label inside a tight flex row.
|
maxWidth |
double? |
Maximum width constraint (logical px).
No
defaultMaxWidth
, deliberately — and
double.infinity
is not a value to lift into one.
It is Flutter's spelling of "no cap" (
BoxConstraints.maxWidth
cannot be null) and it has no Web spelling at all: the base className clamps the inline-flex box with
max-width: 100%
, and the inline emit inside
if (merged.maxWidth != null)
replaces
that parent-relative clamp with a fixed rem. A default would cap every button at an absolute width and remove the clamp that lets it shrink into a narrow pane.
|
padding | CoreEdgeInsets? | Padding override. |
iconLabelGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the gap between leading/trailing icon and label — forwarded straight to the
Gap
widget that separates the slots.
null
defers to [defaultIconLabelGapStyle] (resolvers prefer the size-keyed default from [defaultsBySize] before falling back to the flat default).
|
circleBorderRadius |
CoreBorderRadius? |
Border radius override for
CoreButtonShape.circle
.
null
defers to [defaultCircleBorderRadius] (pill). The shape enum itself lives on the widget; this field overrides the radius the resolver paints for the circle shape.
|
iconShapePadding |
CoreEdgeInsets? |
Padding override for
CoreButtonShape.square
/
CoreButtonShape.circle
.
null
defers to [defaultIconShapePadding] (zero — tight icon container). The shape enum itself lives on the widget; this field overrides the padding the resolver paints for the square / circle shapes.
|
animationDuration |
Duration? |
Animation duration for chrome state transitions (hover / focus / pressed).
null
defers to [defaultAnimationDuration].
|
castHideDuration |
Duration? |
The engage-squash pair's shared clock — how long the shadow takes to hide and the control takes to travel into the space it vacated.
null
defers to [defaultCastHideDuration]. Reduced-motion collapses this to zero on both platforms (
core/design-tokens.md
's transition tier) — state (the hide, the travel) is unaffected.
|
disableHoverEffect |
bool? |
Whether the hover visual effect is suppressed.
null
defers to [defaultDisableHoverEffect].
|
focusOutlineStyle |
CoreFocusOutlineStyle? |
Focus-outline style override. Nested [CoreFocusOutlineStyle] covers ring colour / width / offset / offset-background.
null
(or any unset field on the slot) defers to [defaultFocusOutlineStyle].
|
labelStyle |
CoreTextStyle? |
Label text style override. Nested
CoreTextStyle
so any fontSize / fontWeight / colour subset can be overridden without touching the chrome.
|
leadingIconStyle |
CoreIconStyle? |
Leading icon style override. Nested
CoreIconStyle
so any size / colour subset can be overridden.
|
trailingIconStyle |
CoreIconStyle? |
Trailing icon style override. Nested CoreIconStyle. |
CoreButtonStyle 변형별 기본값 (CoreButtonVariantStyle)#
| 필드 | primary |
secondary |
destructive |
outline |
ghost |
link |
text |
plain |
card |
menu |
menubar |
fixed |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
backgroundColor |
primary | secondary | surface | surface | surfaceContainer (opacity 0) | transparent | transparent | transparent | surface | surfaceContainerHighest (opacity 0, fallback: surfaceContainerHigh) | surfaceContainerHighest (opacity 0, fallback: surfaceContainerHigh) | transparent |
foregroundColor |
onPrimary | onSecondary | error | onSurface | onSurface | onSurfaceVariant | onSurface | onSurfaceVariant | onSurfaceVariant | onSurface | onSurface | onSurface |
hoverBackgroundColor |
primary | secondary | error | surface | surfaceContainer (opacity 0) | transparent | transparent | transparent | surfaceContainerHigh | surfaceContainerHighest (fallback: surfaceContainerHigh) | surfaceContainerHighest (fallback: surfaceContainerHigh) | transparent |
stateLayer |
strong | strong | — | subtle | subtle | — | — | — | — | — | — | — |
disabledBackgroundColor |
disabledContainer | disabledContainer | disabledContainer | outline (opacity 0) | surfaceContainer (opacity 0) | disabledContainer | transparent | disabledContainer | disabledContainer | transparent | transparent | transparent |
disabledForegroundColor |
onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant | onSurfaceVariant |
borderColor |
— | — | error | outline | — | — | — | — | outline | — | — | — |
hoverForegroundColor |
— | — | onError | — | — | onSurface | — | onSurface | — | onSurface | onSurface | — |
hoverBorderColor |
— | — | transparent | outline | — | — | — | — | outline | — | — | — |
pressedBackgroundColor |
— | — | errorStrong | — | — | — | — | — | — | — | — | — |
pressedForegroundColor |
— | — | onError | — | — | — | — | — | — | — | — | — |
pressedBorderColor |
— | — | transparent | — | — | — | — | — | — | — | — | — |
disabledBorderColor |
— | — | disabledOutline | disabledOutline | — | — | — | — | disabledOutline | — | — | — |
borderWidth |
— | — | stroke1 | stroke1 | — | — | — | — | stroke1 | — | — | — |
hoverTextDecoration |
— | — | — | — | — | 'underline' | 'underline' | — | — | — | — | — |
iconColor |
— | — | — | — | — | — | — | — | onSurfaceVariant | onSurface | onSurface | — |
selectedBackgroundColor |
— | — | — | — | — | — | — | — | surfaceContainerHighest | primary | surfaceContainerHighest (fallback: surfaceContainerHigh) | — |
selectedForegroundColor |
— | — | — | — | — | — | — | — | onSurface | onPrimary | — | — |
selectedBorderColor |
— | — | — | — | — | — | — | — | outline | — | — | — |
radius |
— | — | — | — | — | — | — | — | radius16 | — | — | — |
useSelectedState |
— | — | — | — | — | — | — | — | true | true | true | — |
hoverIconColor |
— | — | — | — | — | — | — | — | — | onSurface | onSurface | — |
focusedBackgroundColor |
— | — | — | — | — | — | — | — | — | surfaceContainerHighest (fallback: surfaceContainerHigh) | surfaceContainerHighest (fallback: surfaceContainerHigh) | — |
useFocusedAsHover |
— | — | — | — | — | — | — | — | — | true | true | — |
variant / size / shape 는 위젯 파라미터로 들어가며 CoreButtonStyle
안에는 들어가지 않습니다.
상태 레이어 — 한 메커니즘, 두 티어#
hover / focus / pressed 는 버튼 자기 색의 알파를 낮추는 게 아니라, 그 위에 전경색을 반투명하게 덮는 방식으로 표현합니다. 알파를 낮추면 상태가 바뀔 때마다 브랜드 색이 함께 흐려지기 때문입니다.
강도는 배경의 밝기에 따라 두 티어로 갈립니다. 같은 알파라도 어두운 바탕에서는
지각되는 변화폭이 작기 때문입니다. 위 표의 세 *StateLayerOpacity 행이 그 값입니다.
| tier | 대상 | hover / focus / pressed |
|---|---|---|
| A | 밝은 표면 (outline · ghost) | 3 % / 8 % / 12 % |
| B | 어둡거나 채도 높은 채움 (primary · secondary) |
8 % / 12 % / 16 % |
세 가중치는 오름차순이어야 합니다. 포커스된 버튼에 마우스를 올렸을 때 오버레이가 약해지면 hover 가 아무 일도 안 한 것처럼 보이기 때문입니다. Web 에서는 이 우선순위가 분기가 아니라 규칙을 쓰는 순서로 표현되므로 순서 자체가 계약입니다.
destructive 는 이 표의 대상이 아닙니다. rest 가 흰 배경 + 빨간 테두리,
hover 가 빨간 통짜 채움 — 세기 차이가 아니라 다른 색상이라 어떤 불투명도의
오버레이로도 만들 수 없습니다. 유일한 등재된 예외이며 근거는
docs/decision-log-state-layer-opacity.md 에 있습니다.
text 와 link 는 색이 아니라 밑줄로 신호합니다. 이 킷의 매핑이 primary 계열을
중성으로 접기 때문에, 두 variant 의 쉬는 색과 구별되는 두 번째 중성이 없습니다.
밑줄은 색 강제 모드에서도 살아남는다는 장점도 같이 옵니다.
포커스 링#
링은 분기하지 않습니다 — variant 도, destructive 도 같은 하나를 씁니다.
위 표에 focusOutlineStyle 행이 없는 것이 그 진술입니다: 오버라이드하는 variant 가
하나도 없습니다. 색 2px · 오프셋 2px · 불투명이고, 전부 CoreFocusOutlineStyle
의
공유 기본값에서 옵니다.
variant 는 링 색을 오버라이드할 수 있지만 기하(굵기 · 오프셋 · 투명도)는 안 됩니다. 공유의 요점이 "한 번 바꾸면 전부 따라온다"인데, 자기 값을 박은 variant 하나가 그걸 깨기 때문입니다.
굵기가 2px 인 이유는 WCAG 2.2 SC 2.4.13 이 포커스 표시 두께로 언급하는 값이기 때문입니다 (AAA — AA 준수를 깨는 건 아니지만, 두께를 직접 말하는 유일한 기준입니다). 덤으로 2 는 Tailwind ring 스케일에 있는 값이라 캐시되는 유틸리티 클래스로 나갑니다.
빠른 오버라이드 (Chain)#
이미 만든 Button 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius16처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius16 ==
CoreRadius.radius16) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class ButtonChainExample extends StatelessWidget {
const ButtonChainExample({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Full control').lineLimit(1).ellipsis,
).withStyle(
const CoreButtonStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
);
}
}
class ButtonChainExample extends StatelessComponent {
const ButtonChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Primary').lineLimit(1).ellipsis,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Button(
variant: CoreButtonVariant.primary,
onPressed: () {},
child: Text('Full control').lineLimit(1).ellipsis,
).withStyle(
const CoreButtonStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
변형 (Variants)#
Primary#
가장 강조되는 주요 액션에 사용합니다.
Button(variant: CoreButtonVariant.primary, onPressed: handleAction, child: Text('Primary'))
Secondary#
보조 액션에 사용합니다.
Button(variant: CoreButtonVariant.secondary, onPressed: handleAction, child: Text('Secondary'))
Ghost#
배경 없이 텍스트만 표시합니다. 덜 중요한 액션에 적합합니다.
Button(variant: CoreButtonVariant.ghost, onPressed: handleAction, child: Text('Ghost'))
Outline#
테두리만 있는 버튼입니다.
Button(variant: CoreButtonVariant.outline, onPressed: handleAction, child: Text('Outline'))
Destructive#
위험한 작업(삭제 등)에 사용합니다.
Button(variant: CoreButtonVariant.destructive, onPressed: handleDelete, child: Text('삭제'))
동작 스펙 (Behavior)#
상태 전환#
default→hover→pressed→defaultdisabled상태에서는 모든 인터랙션 무시, 시각적으로 흐리게 표시
포커스 관리#
- Tab 키로 포커스 이동 시 포커스 아웃라인 표시
- 포커스 상태에서 Enter/Space 로 활성화
disableFocusOutline: true로 포커스 아웃라인 제거 가능
애니메이션#
-
hover / pressed 상태 전환:
buttonStyle.animationDuration(기본CoreDuration.fast, 150 ms) disableTransition: true로 트랜지션 비활성화 가능-
hover 시각 효과는 위젯
disableHoverEffect또는buttonStyle.disableHoverEffect로 끕니다. 프로젝트 전역은CoreButtonTheme.style.disableHoverEffect
캐스트 모션 (castMotion)#
buttonStyle.castMotion: CoreCastMotion? 은 버튼이 engage 될 때 그림자(캐스트)가 언제 · 어느 쪽으로 바뀌는지를 정합니다. 세 값입니다:
| 값 | rest | engage | 참조 이름 |
|---|---|---|---|
defaultMotion (기본) |
캐스트가 있음 | 캐스트가 사라지고, 버튼이 그 자리로 이동 | default |
reverse |
캐스트가 없음 | 캐스트가 생기고, 버튼이 반대 방향으로 이동 | reverse |
noShadow | 캐스트 없음 | 변화 없음 (그림자도 이동도) | noShadow |
defaultMotion 과 reverse 는 그림자 유무와 이동 방향이 하나의 짝으로 묶여 있습니다 — 숨김과 이동을 다른 시계에 태우면 한 번의 누름이 두 개의 사건으로 보입니다.
engage — hover 인가 press 인가
"engage" 는 하나의 입력이 아닙니다. 포인터가 있는 기기에서는 hover, 없는 기기에서는 press 가 engage 를 켭니다.
| 기기 | engage 트리거 |
|---|---|
| 포인터 있음 (마우스 · 트랙패드) | hover |
| 포인터 없음 (터치 전용) | press |
이 축이 참조하는 디자인은 웹 전용 라이브러리라 :hover 만 말합니다. 그걸 그대로 옮기면 터치 기기에서 깨집니다 — 터치의 :hover
는 브라우저가 탭을 흉내 내어 켜고 다음 탭까지 꺼지지 않으므로, 버튼이 탭 이후 계속 눌린 모양으로 굳어 있게 됩니다. 그래서 이 축은 참조를 그대로 베끼지 않고 기기 능력으로 갈립니다 — 데스크톱은 참조와 동일하게 hover 로 반응하고, 터치는 press 로 반응합니다. Web 은
@media (hover: hover), Flutter 는 hover 이벤트가 데스크톱에만 도달하는 성질을 그대로 씁니다. press 경로는 터치의 유일한 engage 수단이라 두 플랫폼 모두 지우지 않습니다.
두 가지가 이 값을 덮습니다
-
채움 게이트 — 캐스트는 채워진 박스의 그림자입니다. 그릴 박스가 없는 variant(
ghost·link·plain— 배경도 테두리도 그리지 않음)는castMotion에 무엇을 골라도 캐스트가 없습니다.castMotion은 "무엇을 원하는가"만 답하고, 채움은 "던질 박스가 있는가"를 별도로 답합니다. -
명시된
buttonStyle.boxShadow— 호출자가 그림자를 직접 준 버튼(Fab이 조합하는 elevation 등)에는 이 축이 손대지 않습니다.castMotion은 스타일이 주입한 캐스트만 다루지, 호출자가 명시적으로 요청한 그림자는 이미 의도적인 값이라 그대로 존중합니다.
눈에 보이려면 스타일이 캐스트를 publish 해야 합니다
castMotion 은 언제 캐스트가 바뀌는지만 말합니다. 캐스트 자체(오프셋 · 색 · 두께)는 서페이스 스타일
이 진술합니다. 오늘은 Neo-brutalism 프리셋만 이 값을 publish 합니다 — 나머지(Default · Liquid Glass · Neumorphism · Claymorphism) 아래에서는
castMotion 을 무엇으로 바꾸든 화면이 그대로입니다. 위 라이브 프리뷰가 아무 변화도 안 보인다면 결함이 아니라 활성 프리셋이 이 축을 publish 하지 않는다는 뜻입니다 — 프리셋 스위처로 Neo-brutalism 을 켜면 세 값의 차이가 드러납니다.
leading / trailing#
leading: 버튼 텍스트 앞에 아이콘/위젯 배치trailing: 버튼 텍스트 뒤에 아이콘/위젯 배치buttonStyle.leadingIconStyle/trailingIconStyle로 size/color 미세 조정
사용 가이드라인 (Usage Guidelines)#
✅ Do#
한 화면에 Primary 버튼은 하나만 사용하세요.
Button(variant: CoreButtonVariant.primary, onPressed: handleSave, child: Text('저장'))
Button(variant: CoreButtonVariant.outline, onPressed: handleCancel, child: Text('취소'))
Primary 는 가장 중요한 액션 하나에만 사용하고, 보조 액션은 outline 이나 ghost 를 사용합니다.
❌ Don't#
여러 Primary 버튼을 나란히 배치하지 마세요.
Button(variant: CoreButtonVariant.primary, onPressed: handleSave, child: Text('저장'))
Button(variant: CoreButtonVariant.primary, onPressed: handleCancel, child: Text('취소'))
사용자가 주요 액션을 구분할 수 없어 혼란을 야기합니다.
✅ Do#
버튼 레이블은 동작을 명확히 설명하세요.
Button(variant: CoreButtonVariant.destructive, onPressed: handleDelete, child: Text('계정 삭제'))
사용자가 버튼을 누르기 전에 결과를 예측할 수 있어야 합니다.
❌ Don't#
모호한 레이블을 사용하지 마세요.
Button(variant: CoreButtonVariant.destructive, onPressed: handleDelete, child: Text('확인'))
위험한 작업에 "확인" 만 쓰면 사용자가 결과를 예측할 수 없습니다.
접근성 (Accessibility)#
키보드 인터랙션#
| 키 | 동작 |
|---|---|
Enter | 버튼 활성화 (클릭과 동일) |
Space | 버튼 활성화 (클릭과 동일) |
Tab | 다음 포커스 가능 요소로 이동 |
Shift+Tab | 이전 포커스 가능 요소로 이동 |
스크린 리더#
-
Flutter: 통합
Clickable이Semantics(button: true, enabled: …)를 붙여 역할과 활성 상태를 전달합니다 - Web:
<button>요소로 렌더링되어 네이티브 접근성이 자동 적용됩니다
터치 타겟#
-
최소 터치 타겟 크기: 24×24 (WCAG 2.2
2.5.8).CoreTouchTarget.minimum이 단일 출처이고,TouchTarget(Flutter) /coui-touch-target(Web)이 그리는 크기는 그대로 둔 채 닿는 범위만 넓힙니다. 플랫폼 가이드는 더 큰 값(iOS 44 · Android 48)을 권장하며, 컴포넌트가 그보다 크게 그리는 것은 자유입니다 — 24 는 그 아래로 내려가면 틀린 선입니다.
레거시 vs 통일 비교 (Migration Notes)#
이전 버전의 CoUI 를 참조하는 코드에는 variant 별로 나뉜 별도 클래스(GhostButton · PrimaryButton ·
SecondaryButton · OutlineButton)가 남아 있을 수 있습니다. 이들은 전부 단일 Button
+ CoreButtonVariant enum 으로 통합되었습니다.
| 항목 | 레거시 (GhostButton 등 variant 별 클래스) |
통일 Button(variant:) |
|---|---|---|
| 클래스 수 | variant 마다 별도 클래스 | 단일 Button 클래스 |
| chrome override | 클래스마다 다른 파라미터 집합 | 단일 buttonStyle: CoreButtonStyle? 슬롯 |
| 새 variant 추가 | 새 클래스 필요 | CoreButtonVariant enum 값 추가만 |
마이그레이션:
// 레거시
GhostButton(onPressed: handleAction, child: Text('Ghost'))
OutlineButton(onPressed: handleAction, child: Text('Outline'))
// 통일
Button(variant: CoreButtonVariant.ghost, onPressed: handleAction, child: Text('Ghost'))
Button(variant: CoreButtonVariant.outline, onPressed: handleAction, child: Text('Outline'))
variant 별 클래스를 유지하면 chrome override 경로("widget chrome 단일 진입점" 원칙)가 클래스 수만큼 갈라지므로, 새/수정 코드는 Button(variant:)
하나만 사용하세요.