Button
다양한 변형과 크기를 지원하는 버튼 컴포넌트
목차
사용자 인터랙션을 위한 기본 버튼 컴포넌트입니다. Primary·secondary·tonal·outline·ghost·plain·destructive 변형을 지원합니다. (card
는 variant 가 아닙니다 — outline 에 borderRadius: radius16 을 준 것이 그 모양입니다. 아래 "card 모양" 참조. 링크 모양도 variant 가 아니라 컴포넌트가 맡습니다 — 이동은
Link, 문장 속 링크는 Link.inlineSpan, 동작은 plain 입니다.)
기본 사용법 (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('비활성화'),
)
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,
);
}
}
구조 (Anatomy)#
코드가 정본입니다. 부분 이름은 위젯 슬롯과 같고, Figma ❖ Button
레이어와도 맞습니다.
| 부분 | 역할 | 슬롯 |
|---|---|---|
leading | 레이블 앞 | leading |
child | 버튼 레이블 | child |
trailing | 레이블 뒤 | trailing |
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 폼 제출, 저장, 삭제 등 명확한 액션을 트리거할 때
- 다이얼로그의 확인/취소 버튼이 필요할 때
- 버튼 chrome 이 필요한 마케팅 CTA (
href) — 크롤러가 따라갈 수 있는 진짜 링크
대신 다른 컴포넌트를 사용하세요:
- Toggle: 단일 액션 실행 대신 켜기/끄기 값을 전환할 때
- Link: 버튼 chrome(패딩·배경·radius) 없이 밑줄 텍스트로 이동할 때
- Fab: 떠 있는 주 액션 또는 그 액션 메뉴가 필요할 때
- Menu: 한 버튼의 액션 대신 여러 액션을 항목 목록으로 묶을 때
링크 버튼 (href)#
버튼처럼 보이면서 진짜 링크여야 하는 CTA 입니다. 히어로의 "문의하기", 가격표의 "시작하기"처럼 다른 페이지로 가는 주요 행동이 이 자리입니다.
| 이동 | 버튼 chrome | 크롤러 | |
|---|---|---|---|
Button(onPressed:) |
JS 뿐 | ✅ | ❌ <button> |
Link |
✅ <a href> (콜백이 없을 때) |
❌ 텍스트 밑줄 | ✅ |
Button(href:) |
✅ Web 은 <a href> |
✅ | ✅ |
라우터 Link (jaspr_router) |
✅ 앱 안 이동 | ❌ 직접 스타일 | ✅ |
SEO. Web 에서 href 가 있고 버튼이 활성이면 루트는 <a href="…"> 입니다. 콜백(onPressed)을 같이 줘도 앵커는 유지됩니다 — 분석 클릭이 주소를 지우면 다시
<button> 이 되고, 크롤러·가운데 클릭·새 탭이 사라집니다. onPressed 는 내비게이션을 막지 않습니다.
external: true 는 target="_blank" 와 rel="noopener noreferrer" 를 붙입니다. 같은 탭 링크에는 붙이지 않습니다.
href 만 있고 onPressed 가 없어도 비활성으로 그리지 않습니다. 둘 다 없으면 예전과 같이 비활성입니다.
enabled: false 는 href 가 있어도 비활성 <button> 입니다 — 꺼진 CTA 가 URL 을 공개하지 않고,
:disabled chrome 이 그대로 적용됩니다.
인라인 텍스트 링크는 계속 Link 입니다. Button(href:) 는 버튼 chrome 이 필요한 이동만 맡습니다.
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯입니다.
파라미터가 많아 의미 그룹으로 나눠 적습니다. 이름·순서·기본값은 Flutter·Web 동일하며, 타입만 플랫폼 idiom 을 따릅니다.
콘텐츠 · 시맨틱 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 버튼 본문 (label) |
variant |
CoreButtonVariant |
primary |
시맨틱 변형 (
primary
·
secondary
·
tonal
·
outline
·
ghost
·
plain
·
destructive
·
menu
·
menubar
·
fixed
)
|
size |
CoreComponentSize |
md |
크기 토큰 |
shape |
CoreButtonShape |
rectangle |
형태 (rectangle · square · circle) |
leading | W? | null | 텍스트 앞 아이콘/위젯 |
trailing |
W? |
null |
텍스트 뒤 아이콘/위젯 |
buttonStyle |
CoreButtonStyle? |
null |
chrome / dimensional / nested slot 묶음 (아래 표) |
role |
CoreSurfaceRole |
defaultRole |
콘텐츠/기능 표면 레이어 역할. ARIA 역할이 아님 |
상태 · 레이아웃 파라미터#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
enabled |
bool? |
null |
활성화 여부. null 이면 onPressed != null 또는 비어 있지 않은 href 로 유도 |
href |
String? |
null |
링크 버튼 목적지. Web 은 활성일 때 <a href>. Flutter 는 시맨틱만 |
external |
bool |
false |
새 맥락에서 열기. Web 은
target="_blank"
+
rel="noopener noreferrer"
.
href
가 없거나 비활성이면 무시
|
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 |
클릭 핸들러. href 도 없으면 비활성화. Web 링크 버튼에서는 내비게이션을 막지 않는 추가 콜백 |
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 |
누름 피드백.
null
이면 켜짐 — 누르는 동안 축소(
buttonStyle.pressedScale
)를 양 플랫폼 모두 보이고, Flutter 는 탭 알림과 (모바일에서) 클릭음도 냅니다.
false
면 전부 끔
|
focusNode |
FocusNode? (Flutter) / CoreFocusNode? (Web) |
null |
포커스 노드. Web 은 브라우저 포커스가 그 역할을 대신함 |
statesController |
WidgetStatesController? (Flutter) / CoreStatesController? (Web) |
null |
외부 상태 컨트롤러 |
스타일 시스템 (Style System)#
시맨틱 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: 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,
),
),
},
),
),
)
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
hoverBackgroundColor
. 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
hoverForegroundColor
(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 for the rectangle and square shapes.
null
→ the variant's own [CoreButtonVariantStyle.radius], else the size row's corner (the control height × 0.25) — [borderRadiusForSize].
|
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. May be directional ([CoreEdgeInsets.directional]): a composing component whose two horizontal insets differ because of what sits at the end of the label (a disclosure chevron) states them as start / end, and the button resolves them against the reading direction. |
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.
|
pressedScale |
double? |
Scale the button shrinks to while pressed.
null
defers to [defaultPressedScale];
1.0
turns the shrink off. Rides the [castHideDuration] clock on both platforms, so the press reads as one gesture with the engage pair. The rest render never wears it.
|
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. |
iconShapeIconStyle |
CoreIconStyle? |
The glyph of a
CoreButtonShape.square
/
CoreButtonShape.circle
button — one with no label to sit beside, a step larger than the [leadingIconStyle] of the same size. Read in place of the size row's [leadingIconStyle] when the shape is not
rectangle
; what the instance states here outranks a slot statement ([iconSizeFor] holds the order).
|
CoreButtonStyle 변형별 기본값 (CoreButtonVariantStyle)#
| 필드 | primary |
secondary |
tonal |
destructive |
outline |
ghost |
plain |
menu |
menubar |
fixed |
|---|---|---|---|---|---|---|---|---|---|---|
backgroundColor |
primary | secondaryContainer | primaryContainer | errorContainer | surface | surfaceContainer (opacity 0) | transparent | surfaceContainerHighest (opacity 0, fallback: surfaceContainerHigh) | surfaceContainerHighest (opacity 0, fallback: surfaceContainerHigh) | transparent |
foregroundColor |
onPrimary | onSecondaryContainer | onPrimaryContainer | onErrorContainer | onSurface | onSurface | onSurfaceVariant | onSurface | onSurface | onSurface |
hoverBackgroundColor |
primaryHover | secondaryContainer | primaryContainer | error | surface | surfaceContainer (opacity 0) | transparent | surfaceContainerHighest (fallback: surfaceContainerHigh) | surfaceContainerHighest (fallback: surfaceContainerHigh) | transparent |
pressedBackgroundColor |
primaryPressed | — | — | errorPressed | — | — | — | — | — | — |
disabledBackgroundColor |
disabledContainer | disabledContainer | disabledContainer | disabledContainer | outline (opacity 0) | surfaceContainer (opacity 0) | transparent | transparent | transparent | transparent |
disabledForegroundColor |
disabledContent | disabledContent | disabledContent | disabledContent | disabledContent | disabledContent | disabledContent | disabledContent | disabledContent | disabledContent |
stateLayer |
— | subtle | subtle | — | subtle | subtle | — | — | — | — |
hoverForegroundColor |
— | — | — | onError | — | — | onSurface | onSurface | onSurface | — |
pressedForegroundColor |
— | — | — | onError | — | — | — | — | — | — |
borderColor |
— | — | — | — | outlineVariant | — | — | — | — | — |
hoverBorderColor |
— | — | — | — | outlineVariant | — | — | — | — | — |
disabledBorderColor |
— | — | — | — | disabledOutline | — | — | — | — | — |
borderWidth |
— | — | — | — | stroke1 | — | — | — | — | — |
paddingInline |
— | — | — | — | — | — | space0 | — | — | — |
iconColor |
— | — | — | — | — | — | — | onSurface | onSurface | — |
hoverIconColor |
— | — | — | — | — | — | — | onSurface | onSurface | — |
selectedBackgroundColor |
— | — | — | — | — | — | — | primary | surfaceContainerHighest (fallback: surfaceContainerHigh) | — |
selectedForegroundColor |
— | — | — | — | — | — | — | onPrimary | — | — |
focusedBackgroundColor |
— | — | — | — | — | — | — | surfaceContainerHighest (fallback: surfaceContainerHigh) | surfaceContainerHighest (fallback: surfaceContainerHigh) | — |
useSelectedState |
— | — | — | — | — | — | — | true | true | — |
useFocusedAsHover |
— | — | — | — | — | — | — | true | true | — |
variant / size / shape 는 위젯 파라미터로 들어가며 CoreButtonStyle
안에는 들어가지 않습니다.
인터랙션 상태 — 채움은 컨테이너를 바꾸고, 옅은·비채움은 워시를 얹는다#
hover / pressed 를 표현하는 방식은 variant 가 어떤 채움인가로 갈립니다.
채워진 variant (primary · destructive) 는 컨테이너 색 자체를 바꿉니다.
primary 는 hover 가 primaryHover, pressed 가 primaryPressed
이고,
destructive 는 평소 옅은 errorContainer, hover 가 error, pressed 가
errorPressed 입니다.
전부 ColorScheme 의 불투명 슬롯이고, 모드(light · dark · 고대비)마다 Figma 의
color/*-hover · color/*-pressed 변수를 실측한 값입니다.
primary 의 글자색은 상태가 바뀌어도
그대로(onPrimary)이고, destructive 는 평소 onErrorContainer
에서 채워지는 hover · pressed 에
onError 로 바뀝니다. 워시가 아니라 스왑인 이유는 두 가지입니다.
이미 어둡거나 채도 높은 채움 위에 알파를 얹으면 지각되는 변화폭이 너무 작고,
어느 페이지 위에 있든 같은 결과가 나와야 하기 때문입니다.
옅은 채움과 비채움 variant (secondary · tonal · outline · ghost) 는 평소 모양 위에
전경색을 반투명하게 덮습니다 (CoreStateLayer.subtle — hover 3 % / pressed 12 %).
secondary 는 secondaryContainer 채움 위에 onSecondaryContainer
를, tonal 은
primaryContainer 채움 위에 onPrimaryContainer 를, outline
·
ghost 는 onSurface 를 덮습니다. 알파를 낮추는 방식과 달리 브랜드 색이 흐려지지 않고,
채움은 평소 그대로입니다.
두 가중치는 오름차순이어야 합니다 (hover ≤ pressed). Web 에서는 pressed 가 hover 를 이기는 우선순위가 분기가 아니라 규칙을 쓰는 순서로 표현되므로 순서 자체가 계약입니다.
한 variant 는 둘 중 하나만 씁니다. 스왑된 채움 위에 워시를 또 얹으면 두 번
어두워지고, Web 에서는 color-mix() 규칙이 specificity 로 스왑 클래스를 덮어 스왑이
보이지 않게 됩니다. 그래서 primary / destructive 행의 stateLayer
는 비어 있습니다.
포커스는 상태가 아니라 링입니다. 키보드 포커스를 받은 버튼은 평소 모양 그대로에
포커스 링만 더합니다 — 채움도 글자도 바뀌지 않고 워시도 없습니다. 링이 이미 포커스
지시자이고, 채움 변화나 8 % 워시는 WCAG 2.4.13 이 요구하는 3:1 변화에 못 미치기
때문입니다. 세 구현(Flutter · Web resolver · Figma 빌더)이 같은 규칙입니다.
예외는 포커스를 hover 로 접도록 useFocusedAsHover 를 켠 menu
· menubar 두 행뿐입니다.
비활성 글자와 아이콘은 모든 variant 가 disabledContent 한 색입니다. onSurfaceVariant
는
활성 보조 글자 색이라, 그 색으로는 비활성이 조용한 활성 버튼과 구분되지 않았습니다.
포커스 링#
링은 분기하지 않습니다 — variant 도, destructive 도 같은 하나를 씁니다.
위 표에 focusOutlineStyle 행이 없는 것이 그 진술입니다: 오버라이드하는 variant 가
하나도 없습니다. 색 2px · 오프셋 2px · 불투명이고, 전부 CoreFocusOutlineStyle
의
공유 기본값에서 옵니다.
링과 버튼 사이 2px 간격은 투명입니다 — 버튼이 놓인 배경이 그대로 보여 링이
버튼에서 떨어져 읽힙니다. 간격을 surface 밴드로 칠하는 것은 버튼 뒤의 배경을 알 수
없는 합성처뿐입니다(콘텐츠 위에 떠 있는 Fab, 반전 표면 위의 Toast 닫기
버튼처럼). 그런 합성처는 자기가 Button 에 넘기는 buttonStyle 기본값의
focusOutlineStyle.offsetColor 로 밴드를 켭니다.
variant 는 링 색을 오버라이드할 수 있지만 기하(굵기 · 오프셋 · 투명도)는 안 됩니다. 공유의 요점이 "한 번 바꾸면 전부 따라온다"인데, 자기 값을 박은 variant 하나가 그걸 깨기 때문입니다.
굵기가 2px 인 이유는 WCAG 2.2 SC 2.4.13 이 포커스 표시 두께로 언급하는 값이기 때문입니다 (AAA — AA 준수를 깨는 건 아니지만, 두께를 직접 말하는 유일한 기준입니다). 덤으로 2 는 Tailwind outline 스케일에 있는 값이라 캐시되는 유틸리티 클래스로 나갑니다.
빠른 오버라이드 (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)#
card 모양 — outline + 박스 반경#
누를 수 있는 박스는 Button 의 variant 가 아닙니다 — outline 에 박스 반경을 준 것이
그 모양이고, 선택 상태는 소비자가 표현합니다. Figma Button 변형 세트에도 그런 멤버는
없습니다. 이 레시피가 정본이며, 아래 갤러리의 "card" 탭도 이것을 렌더합니다.
// variant 로 만들려던 것
Button(variant: CoreButtonVariant.card, onPressed: () {}, child: Text('Card'))
// 정본 — 같은 모양
Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: Text('Card'))
.withStyle(
const CoreButtonStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
)
사이즈 래더 — padding-x · gap · icon#
| size | height | padding-x | icon↔label gap | label | icon | icon (square · circle) |
|---|---|---|---|---|---|---|
xs | 24 | 8 | 4 | label-small | 12 | 14 |
sm | 32 | 12 | 6 | label-medium | 14 | 16 |
md | 40 | 16 | 6 | label-large | 16 | 18 |
lg | 48 | 20 | 6 | label-xl2 | 18 | 20 |
xl | 56 | 24 | 8 | label-xl2 | 20 | 24 |
xl2 | 64 | 28 | 8 | label-xl2 | 24 | 28 |
라벨 옆 아이콘(icon)은 사이즈 행의 leadingIconStyle · trailingIconStyle, 라벨 없이 아이콘만
넣는 shape: .square · .circle 버튼의 아이콘(icon (square · circle))은 같은 행의
iconShapeIconStyle 에 있습니다 — 라벨이 없으면 한 단계 큰 아이콘이 같은 높이에서 균형이 맞습니다.
호출부가 leadingIconStyle 로 아이콘 크기를 직접 말하면(shape 와 함께여도) 그 값이 행을 이기고,
buttonStyle.iconShapeIconStyle 을 말하면 그 값이 슬롯 진술까지 이깁니다. 순서는
CoreButtonStyle.iconSizeFor 한 곳에 있고 양 플랫폼 resolver 가 같은 함수를 부릅니다. 아이콘의
획 두께는 크기가 정하는 규칙 하나(CoreIconStroke)가 맡아, 표에 따로 적지 않습니다.
shape: .square · .circle 의 폭은 두 플랫폼 모두 높이와 같습니다 — 두 shape 는 padding 이
CoreButtonStyle.defaultIconShapePadding(= .zero)이라 내용만으로는 아이콘 크기가 폭이 되지만,
Flutter 는 minWidth 를 높이로 걸고 Web 은 aspect-square
를 얹습니다.
반경은 사이즈 행에 있고, 컨트롤 높이 × 0.25 입니다 — xs 6 · sm 8 · md
10 ·
lg 12 · xl 14 · xl2 16 (CoreButtonStyle.defaultsBySize[size].borderRadius, Figma 에서는
Primitives/radius/N 에 바인딩). 같은 사이즈의 TextField · TextArea · Select 트리거와 혼자 선
MenuItem 도 같은 값입니다. 반경 하나를 모든 사이즈에 쓰면 작은 버튼이 큰 버튼보다 둥글어
보입니다(8 하나면 r/h 가 xs 0.33 에서 xl2 0.125 까지 벌어집니다) — 높이에 비례하는
모서리는 어느 사이즈에서도 같은 둥글기로 읽힙니다. 예외는 모양뿐입니다 — shape: .circle 은
9999 이고, .square 는 같은 사이즈 행을 따르며, variant 가 자기 반경을 진술하면 그 값이
이깁니다(오늘 그러는 variant 는 없습니다 — 그 자리는 withStyle 로 반경을 주는 호출자를
위해 열려 있습니다). 그 우선순위는 CoreButtonStyle.borderRadiusForSize(size:, variantRadius:)
한 곳에 있고 양 플랫폼 resolver 가 같은 함수를 부릅니다. 사이즈 없는 defaultBorderRadius
·
borderRadiusFor 는 폐기 예고 상태이고(시작 버전은 @Deprecated
메시지의 since), 예고
기간 동안 예전 값 8 을 그대로 답합니다. 포커스 밴드 · 링은 토큰이 아니라 이 반경에서 계산됩니다 (밴드 R+2 · 링 R+4 —
md 는 12 · 14, xl2 는 18 · 20; 사다리 밖 값이어도 그대로 둡니다).
다른 컴포넌트가 사이즈를 말하지 않고 합성한 버튼은 md 의 10 을 받습니다. 바깥 모서리보다
둥글어지는 자리만 부모가 자기 중첩 Style 기본값으로 반경을 고정합니다 — TextField 의
지우기 · 비밀번호 버튼은 필드의 사이즈 행 반경, Toast 닫기 버튼은 패널 반경(8)입니다.
plain 은 inline padding 이 0 입니다
(CoreButtonStyle.paddingFor) — 주변 텍스트와 나란히 놓이는 affordance 라 사이즈
래더의 좌우 여백이 그 기준선을 밀어내기 때문입니다. xs 의 라벨은 12px(label-small)
입니다 — 10px 은 라벨을 읽는 하한 밑이고, 12px 은 24 높이 안에 들어갑니다
(12 × 1.4 + 위아래 패딩 2 × 2 = 20.8).
라벨 굵기는 사이즈 행의 라벨 역할이 정합니다 — 모든 variant 가 역할의 500 입니다. 같은 크기의 다른
라벨과 같은 굵기로 그려지고, Figma 캔버스에서는 그 역할의 텍스트 스타일(Label/*)이 라벨에 붙습니다.
CoreButtonVariantStyle.labelStyle 은 variant 가 굵기를 따로 말해야 할 때 쓰는 자리이고, 지금은 어느
variant 도 쓰지 않습니다. 이 슬롯에서 읽는 것은 굵기뿐입니다 — 크기 · 행간 · 자간은 사이즈 행의 역할이
정합니다. 순서는 사이즈 행의 역할 → variant 굵기 → 테마 · 인스턴스의 labelStyle
(CoreButtonStyle.labelStyle)이고 CoreButtonStyle.labelStyleFor
한 곳에 있습니다. 테마 · 인스턴스의
labelStyle 이 역할 토큰이나 굵기를 말하면 variant 굵기는 물러납니다 — 자기 라벨의 역할을 스스로 정한
합성(Calendar 의 날짜 칸이 body 역할을 적는 것)을 덮지 않게 하려는 규칙입니다.
Primary#
가장 강조되는 주요 액션에 사용합니다.
Button(variant: CoreButtonVariant.primary, onPressed: handleAction, child: Text('Primary'))
Secondary#
보조 액션에 사용합니다. 진한 회색이 아니라 옅은 채움(secondaryContainer)이라 primary
와
구분됩니다 — 확인 대화상자의 "취소"가 이 자리입니다.
Button(variant: CoreButtonVariant.secondary, onPressed: handleAction, child: Text('Secondary'))
Tonal#
옅은 브랜드 면(primaryContainer)에 브랜드 글자(onPrimaryContainer)입니다. 카드 안의
보조 행동처럼 primary 보다 한 단계 약하고 secondary 보다 한 단계 브랜드에 가까운 강조에
씁니다. 구조와 상태 규칙은 secondary 와 같습니다 — 테두리가 없고, hover · pressed 는 채움을
그대로 두고 글자색을 3 % · 12 % 덮으며, 포커스는 평소 모양에 링만 더하고, 비활성은
disabledContainer · disabledContent 입니다.
Card(
child: Row(
children: [
const Expanded(child: Text('새 버전이 있습니다')),
Button(
variant: CoreButtonVariant.tonal,
size: CoreComponentSize.sm,
onPressed: handleUpdate,
child: const Text('업데이트'),
),
],
),
)
Ghost#
배경 없이 텍스트만 표시합니다. 덜 중요한 액션에 적합합니다.
Button(variant: CoreButtonVariant.ghost, onPressed: handleAction, child: Text('Ghost'))
로케일 스위처 — ghost / plain#
네비의 언어 버튼은 테두리도 채움도 없는 조용한 컨트롤입니다. 별도 borderless variant 는 없습니다. ghost 와 plain
이 그 크롬이고, 둘 다 borderWidth 기본값이 0 입니다.
ghost | plain | |
|---|---|---|
| 채움 | 투명. hover 는 onSurface 를 얇게 덮는 state layer |
투명. hover 에도 채움 없음 |
| 글자 | onSurface |
휴식 onSurfaceVariant, hover onSurface |
| 좌우 패딩 | 사이즈 래더 | 0 (paddingInline) |
언어 코드처럼 주변 텍스트와 나란히 두면 plain 입니다. 호버 워시가 있는 버튼이면 ghost 입니다. 반경·라벨 역할은 스타일 슬롯으로 덮습니다. Web 은 이 값을 컴포넌트 inline / 유틸리티로 그리므로, 제품 CSS 로 같은 속성을 덮으려면
!important 가 필요합니다. 슬롯을 쓰면 그 싸움이 없습니다.
Button(
variant: CoreButtonVariant.plain,
onPressed: openLocale,
buttonStyle: const CoreButtonStyle(
labelStyle: CoreTextStyle.token(CoreTextStyles.labelMedium),
),
child: const Text('KO'),
)
Outline#
테두리만 있는 버튼입니다. 테두리는 outlineVariant — 안의 글자가 버튼임을 알려주므로
테두리는 옅은 경계선으로 둡니다. rest · hover · focus · pressed · selected 가 같은 테두리이고,
비활성만 disabledOutline 입니다.
Button(variant: CoreButtonVariant.outline, onPressed: handleAction, child: Text('Outline'))
Destructive#
위험한 작업(삭제 등)에 사용합니다. 평소에는 옅은 빨강 바탕(errorContainer)에 빨강 글자
(onErrorContainer)이고 테두리가 없습니다 — 위험한 동작이 화면에서 가장 센 버튼으로 읽히지 않게
합니다. hover 에서 error 로, pressed 에서 errorPressed
로 채워지고 글자는 onError 가 됩니다.
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로 트랜지션 비활성화 가능-
누르는 동안 버튼이
buttonStyle.pressedScale(기본CoreButtonStyle.defaultPressedScale, 0.95)로 줄어듭니다. 데스크톱 · 모바일 · Web 모두 같고, 쉬는 상태의 렌더는 바뀌지 않습니다. 시계는castHideDuration입니다.pressedScale: 1이나enableFeedback: false로 끕니다. 캐스트가 있는 버튼이 그 자리로 이동하는 스타일(neo-brutalism)에서는 이동이 누름을 말하므로 축소하지 않습니다 — 캐스트가 없는 버튼은 이동할 자리가 없으므로 그대로 축소합니다 -
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·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)#
역할#
-
Flutter — 합성된
Clickable이Semantics(button: true, enabled: ...)를 붙입니다.href가 있고 활성이면 같은 노드에Semantics(link: true, linkUrl:)가 합쳐집니다. 실제 이동은 호스트의onPressed가 담당합니다. -
Web — 액션 버튼은 네이티브
<button type="button">,href가 있고 활성이면<a href>입니다. 역할은 태그의 암묵적 역할을 씁니다.enabled: false는 URL 을 내보내지 않는<button disabled>로 돌아갑니다.
aria-label 같은 임의 속성은 Web 에서만 attributes 로 통과시킬 수 있습니다 — Flutter 생성자에는 대응 슬롯이 없습니다(아래 알려진 제약 참고).
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter | 활성화 (onPressed) | 버튼 활성화 / 링크 이동 |
Space |
활성화 (onPressed) |
<button> 활성화. 네이티브 링크는 이동하지 않음 |
Tab | 다음 포커스 가능 요소로 | 다음 포커스 가능 요소로 |
Shift+Tab | 이전 포커스 가능 요소로 | 이전 포커스 가능 요소로 |
Flutter 는 Clickable 의 활성화 단축키(Enter · Space → ActivateIntent)를, Web 은 네이티브 버튼·앵커의 키보드 동작을 씁니다. 비활성 버튼은 양 플랫폼 모두
Tab 순회에서 빠집니다 — Flutter 는 FocusableActionDetector(enabled: false), Web 은 tabindex="-1".
Escape · 화살표 등 다른 키는 Button 이 처리하지 않습니다.
스크린 리더#
접근 가능한 이름은 기본적으로 child 의 내용에서 옵니다 — 양 플랫폼 모두 Button 자체에 라벨 슬롯이 없습니다. 텍스트를 넣으면 그대로 읽히고, 아이콘만 넣으면 이름이 비어 역할만 읽힙니다. Web 은
attributes: {'aria-label': ...} 로 이름을 직접 실을 수 있고(호출부 속성이 나중에 병합되어 이깁니다), Flutter 에는 그 경로가 없습니다.
활성/비활성은 양쪽 다 통보됩니다(Flutter Semantics(enabled:), Web aria-disabled). 선택(selected) 상태는 Flutter 에서 통보되지 않습니다
— 아래 알려진 제약 참고.
누른 결과를 알리는 live region 은 없습니다. 버튼이 무엇을 바꿨는지는 호출부가 알려야 합니다.
포커스 관리#
- 진입 — 이동 없음. 오버레이가 아니라 인라인 컨트롤이라 마운트 시 포커스를 옮기지 않습니다.
- 이탈 — 해당 없음.
- 트랩 — 없음.
Tab은 버튼을 지나 그대로 빠져나갑니다. -
포커스 표시 — 양쪽 다 키보드 포커스일 때만 링이 나타납니다. Flutter 는
FocusOutline을Clickable의onShowFocusHighlight로 게이트하고, Web 은focus-visible:outline-*유틸리티(링 값이 토큰 스케일 밖이거나 밴드를 켜면 scoped:focus-visible규칙)를 씁니다.
알려진 제약#
-
아이콘만 넣은 버튼에는 접근 가능한 이름이 생기지 않으며, Flutter 에는 이름을 넘길 슬롯이 없습니다.
Button도Icon도semanticLabel을 받지 않고 이름은child의 내용에서만 오는데, 아이콘은 텍스트를 내놓지 않습니다. Web 은Button(attributes: {'aria-label': ...})로 붙일 수 있지만, Flutter 생성자에는attributes도semanticLabel도 없어 호출부가 버튼을Semantics(label: ...)로 감싸야 합니다. -
버튼을 선택 상태로 쓰면 그 사실이 Flutter 에서는 통보되지 않습니다. Web 은 선택 chrome 이
aria-selected:변형 클래스에 걸려 있어, 그 chrome 을 보려면 호출부가attributes로aria-selected를 실어야 하고 그러면 상태가 접근성 트리에도 함께 들어갑니다. Flutter 는 같은 chrome 을statesController나 상위WidgetStatesData의WidgetState.selected로 받는데Semantics에는 반영하지 않아, 화면은 선택된 것으로 보이지만 리더는 그렇게 읽지 않습니다. -
disableFocusOutline: true는 Flutter 에서만 동작합니다. Flutter 는 이 값으로FocusOutline을 끄지만, WebButton은 이 필드를 선언·복사만 하고 어디에서도 읽지 않아 포커스 링이 그대로 남습니다. 두 플랫폼의 포커스 표시가 갈리므로, 링을 끄기로 한 판단이 Web 에는 적용되지 않는다는 것을 알고 써야 합니다. -
임의 duration 의 동작 줄이기 경로는 플랫폼마다 다릅니다. Web 은
durationMsCss로 토큰 스텝을 CSS 변수에 연결하지만 스케일 밖 값은 리터럴로 내보냅니다. 토큰 전환 tier 는 reduced-motion 변수 규칙이 억제합니다. 공통 모션 정책이 필요하면CoreDuration토큰을 쓰세요.
크로스 플랫폼 차이점 (Platform Differences)#
| Web | Flutter | |
|---|---|---|
href 렌더 |
활성이면 <a href> |
앵커 없음. Semantics.link + linkUrl |
| 이동 | 브라우저가 따라감. 라우터가 앵커 클릭을 가로채면 그 라우터의 일 | 킷이 라우팅하지 않음. 호스트가 onPressed 로 이동 |
external: true |
target="_blank" + rel="noopener noreferrer" |
선언만. 호스트가 외부 브라우저를 연다 |
enabled: false |
<button disabled>, URL 비공개 |
비활성 버튼. 링크 시맨틱 없음 |
| 키보드 | 링크는 Enter. Space 는 <button> 만 |
Clickable 의 버튼 활성화 (Enter/Space). 이동은 onPressed |
기능은 양쪽에 있고 메커니즘만 다릅니다. 마케팅 사이트의 크롤 가능한 CTA 는 Web 의 <a href> 가 정본입니다.
레거시 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:)
하나만 사용하세요.