Button | CoUI
본문으로 건너뛰기
LogoCoUI

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#

구조 (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 을 따릅니다.

콘텐츠 · 시맨틱 파라미터#

속성타입기본값설명
childW필수버튼 본문 (label)
variant CoreButtonVariant primary 시맨틱 변형 ( primary · secondary · tonal · outline · ghost · plain · destructive · menu · menubar · fixed )
size CoreComponentSize md 크기 토큰
shape CoreButtonShape rectangle 형태 (rectangle · square · circle)
leadingW?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)
xs2484label-small1214
sm32126label-medium1416
md40166label-large1618
lg48206label-xl21820
xl56248label-xl22024
xl264288label-xl22428

라벨 옆 아이콘(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 입니다.

ghostplain
채움 투명. 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 → default
  • disabled 상태에서는 모든 인터랙션 무시, 시각적으로 흐리게 표시

포커스 관리#

  • 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 될 때 그림자(캐스트)가 언제 · 어느 쪽으로 바뀌는지를 정합니다. 세 값입니다:

값restengage참조 이름
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 생성자에는 대응 슬롯이 없습니다(아래 알려진 제약 참고).

키보드#

키FlutterWeb
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 을 끄지만, Web Button 은 이 필드를 선언·복사만 하고 어디에서도 읽지 않아 포커스 링이 그대로 남습니다. 두 플랫폼의 포커스 표시가 갈리므로, 링을 끄기로 한 판단이 Web 에는 적용되지 않는다는 것을 알고 써야 합니다.
  • 임의 duration 의 동작 줄이기 경로는 플랫폼마다 다릅니다. Web 은 durationMsCss 로 토큰 스텝을 CSS 변수에 연결하지만 스케일 밖 값은 리터럴로 내보냅니다. 토큰 전환 tier 는 reduced-motion 변수 규칙이 억제합니다. 공통 모션 정책이 필요하면 CoreDuration 토큰을 쓰세요.

크로스 플랫폼 차이점 (Platform Differences)#

WebFlutter
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:) 하나만 사용하세요.

  • FAB: 화면의 주요 플로팅 액션 버튼. 한 화면에 하나의 주요 액션을 강조할 때 사용
  • Toggle: 켜기/끄기 상태를 전환하는 컴포넌트. 버튼과 달리 상태를 유지
  • Menu: 여러 액션을 묶어 제공할 때. 버튼 클릭으로 메뉴를 열 수 있음