Button | CoUI
LogoCoUI

Button

다양한 변형과 크기를 지원하는 버튼 컴포넌트

Button#

사용자 인터랙션을 위한 기본 버튼 컴포넌트입니다. Primary·secondary·outline·ghost·link·plain·card·destructive 변형을 지원합니다.

Live Preview#

사용 시기 (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.
paddingCoreEdgeInsets?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 에 있습니다.

textlink 는 색이 아니라 밑줄로 신호합니다. 이 킷의 매핑이 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)#

상태 전환#

  • defaulthoverpresseddefault
  • disabled 상태에서는 모든 인터랙션 무시, 시각적으로 흐리게 표시

포커스 관리#

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

restengage참조 이름
defaultMotion (기본) 캐스트가 있음 캐스트가 사라지고, 버튼이 그 자리로 이동 default
reverse 캐스트가 없음 캐스트가 생기고, 버튼이 반대 방향으로 이동 reverse
noShadow캐스트 없음변화 없음 (그림자도 이동도)noShadow

defaultMotionreverse 는 그림자 유무와 이동 방향이 하나의 짝으로 묶여 있습니다 — 숨김과 이동을 다른 시계에 태우면 한 번의 누름이 두 개의 사건으로 보입니다.

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: 통합 ClickableSemantics(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:) 하나만 사용하세요.

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