按钮组件设计详解:从设计原则到完整实现

HTMLPAGE 团队
15 分钟阅读

深入探讨按钮组件的设计原则、视觉规范、交互状态和可访问性要求,提供完整的设计令牌定义和组件实现方案,帮助构建一致、易用的按钮系统。

#按钮设计 #组件库 #设计系统 #UI组件 #可访问性

按钮组件设计详解:从设计原则到完整实现

按钮是用户界面中最基础也最重要的交互元素。一个设计良好的按钮系统能够提升用户体验、保持界面一致性,并降低开发维护成本。本文将从设计原则、视觉规范到代码实现,全面解析如何构建专业级的按钮组件。

按钮的重要性

在任何应用中,按钮都承担着关键职责:

用户行为引导

按钮是用户与系统交互的主要触发点,引导用户完成表单提交、页面跳转、操作确认等关键动作。按钮的设计直接影响用户能否顺利完成任务。

信息层级表达

通过不同的按钮样式(主要、次要、文字、危险等),可以清晰表达操作的重要程度和性质,帮助用户快速理解界面结构。

品牌形象传递

按钮的颜色、形状、动效是品牌视觉语言的重要组成部分,一致的按钮风格能够强化品牌认知。

按钮分类体系

一个完整的按钮系统通常包含以下类型:

按语义分类

类型用途视觉特征使用场景
Primary主要操作实心填充,品牌色表单提交、关键CTA
Secondary次要操作边框样式,无填充取消、返回
Tertiary辅助操作纯文字,无边框链接式操作
Danger危险操作红色系删除、移除
Success成功操作绿色系确认、完成
Warning警告操作橙/黄色系需谨慎的操作
Ghost幽灵按钮透明背景深色背景上使用

按尺寸分类

Large   (48px)  ████████████████████  大型表单、移动端
Medium  (40px)  ██████████████████    默认尺寸
Small   (32px)  ████████████████      表格内、紧凑布局
Mini    (24px)  ██████████████        标签内、极紧凑场景

按状态分类

  • Default:默认状态
  • Hover:鼠标悬停
  • Active/Pressed:按下状态
  • Focus:键盘聚焦
  • Disabled:禁用状态
  • Loading:加载中

设计令牌定义

设计令牌(Design Tokens)是设计系统的基石,定义了按钮的所有可配置属性:

颜色令牌

// colors/_button.scss

// Primary 按钮颜色
$button-primary-bg: $color-brand-500;
$button-primary-bg-hover: $color-brand-600;
$button-primary-bg-active: $color-brand-700;
$button-primary-text: $color-white;
$button-primary-border: transparent;

// Secondary 按钮颜色
$button-secondary-bg: transparent;
$button-secondary-bg-hover: $color-gray-50;
$button-secondary-bg-active: $color-gray-100;
$button-secondary-text: $color-gray-700;
$button-secondary-border: $color-gray-300;
$button-secondary-border-hover: $color-gray-400;

// Danger 按钮颜色
$button-danger-bg: $color-red-500;
$button-danger-bg-hover: $color-red-600;
$button-danger-bg-active: $color-red-700;
$button-danger-text: $color-white;

// Ghost 按钮颜色
$button-ghost-bg: transparent;
$button-ghost-bg-hover: rgba($color-white, 0.1);
$button-ghost-text: $color-white;
$button-ghost-border: $color-white;

// 禁用状态
$button-disabled-bg: $color-gray-100;
$button-disabled-text: $color-gray-400;
$button-disabled-border: $color-gray-200;

尺寸令牌

// sizes/_button.scss

// 高度
$button-height-large: 48px;
$button-height-medium: 40px;
$button-height-small: 32px;
$button-height-mini: 24px;

// 内边距 (水平)
$button-padding-large: 24px;
$button-padding-medium: 20px;
$button-padding-small: 16px;
$button-padding-mini: 12px;

// 字体大小
$button-font-size-large: 16px;
$button-font-size-medium: 14px;
$button-font-size-small: 14px;
$button-font-size-mini: 12px;

// 图标尺寸
$button-icon-size-large: 20px;
$button-icon-size-medium: 18px;
$button-icon-size-small: 16px;
$button-icon-size-mini: 14px;

// 图标与文字间距
$button-icon-gap: 8px;

// 圆角
$button-radius-default: 6px;
$button-radius-round: 9999px;
$button-radius-square: 0;

动效令牌

// animation/_button.scss

// 过渡时长
$button-transition-duration: 150ms;

// 缓动函数
$button-transition-timing: cubic-bezier(0.4, 0, 0.2, 1);

// 按下缩放
$button-active-scale: 0.98;

// 聚焦环配置
$button-focus-ring-width: 3px;
$button-focus-ring-offset: 2px;
$button-focus-ring-color: rgba($color-brand-500, 0.4);

视觉规范

最小触摸区域

根据各平台指南,按钮的最小触摸区域:

  • iOS: 44 × 44 pt
  • Android: 48 × 48 dp
  • Web: 44 × 44 px (建议)

即使视觉尺寸较小,也应通过 padding 或点击区域扩展保证可触摸区域足够大。

文字规范

✓ 使用动词或动词短语: "保存", "提交订单", "下一步"
✓ 保持简洁: 2-4个字最佳
✓ 首字母大写(英文): "Submit", "Save Changes"
✗ 避免全大写: "SUBMIT" (可读性差)
✗ 避免过长文本: "点击这里保存您的更改"

图标使用原则

[icon] 文字    左图标:表示操作性质(如 + 新建)
文字 [icon]    右图标:表示操作结果(如 下载 ↓)
[icon]         纯图标:需要 tooltip 说明

交互状态设计

状态转换流程

                    ┌──────────────┐
                    │   Default    │
                    └──────┬───────┘
                           │
           ┌───────────────┼───────────────┐
           │               │               │
           ▼               ▼               ▼
    ┌──────────┐    ┌──────────┐    ┌──────────┐
    │  Hover   │    │  Focus   │    │ Disabled │
    └────┬─────┘    └────┬─────┘    └──────────┘
         │               │
         │    ┌──────────┘
         │    │
         ▼    ▼
    ┌──────────────┐
    │    Active    │
    └──────┬───────┘
           │
           ▼
    ┌──────────────┐
    │   Loading    │ (可选)
    └──────────────┘

各状态视觉变化

// Primary 按钮状态示例
.button-primary {
  // Default
  background-color: $button-primary-bg;
  color: $button-primary-text;
  border: 1px solid $button-primary-border;
  
  // Hover - 颜色加深
  &:hover:not(:disabled) {
    background-color: $button-primary-bg-hover;
  }
  
  // Active - 更深 + 轻微缩放
  &:active:not(:disabled) {
    background-color: $button-primary-bg-active;
    transform: scale($button-active-scale);
  }
  
  // Focus - 聚焦环
  &:focus-visible {
    outline: none;
    box-shadow: 
      0 0 0 $button-focus-ring-offset $color-white,
      0 0 0 ($button-focus-ring-offset + $button-focus-ring-width) $button-focus-ring-color;
  }
  
  // Disabled - 降低对比度
  &:disabled {
    background-color: $button-disabled-bg;
    color: $button-disabled-text;
    cursor: not-allowed;
    opacity: 0.6;
  }
}

Loading 状态设计

加载状态需要同时考虑视觉和行为:

.button-loading {
  // 禁用点击
  pointer-events: none;
  
  // 保持按钮尺寸稳定
  position: relative;
  
  // 文字半透明
  .button-text {
    opacity: 0.3;
  }
  
  // 加载图标
  .button-spinner {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    
    animation: spin 1s linear infinite;
  }
}

@keyframes spin {
  from { transform: translate(-50%, -50%) rotate(0deg); }
  to { transform: translate(-50%, -50%) rotate(360deg); }
}

可访问性要求

颜色对比度

确保按钮文字与背景的对比度符合 WCAG 标准:

级别普通文本大文本
AA4.5:13:1
AAA7:14.5:1
// 对比度检查工具函数
function getContrastRatio(foreground, background) {
  const getLuminance = (rgb) => {
    const [r, g, b] = rgb.map(c => {
      c = c / 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };
  
  const l1 = getLuminance(foreground);
  const l2 = getLuminance(background);
  
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}

// 使用示例
const ratio = getContrastRatio([255, 255, 255], [59, 130, 246]);
console.log(`对比度: ${ratio.toFixed(2)}:1`); // 约 4.5:1

键盘支持

按钮必须支持完整的键盘操作:

按键行为
Tab聚焦到按钮
Enter触发点击
Space触发点击
Escape取消操作(某些场景)
<!-- 正确的语义化写法 -->
<button type="button" class="button-primary">
  保存
</button>

<!-- 如果必须用其他元素,需要添加 ARIA 属性 -->
<div 
  role="button" 
  tabindex="0"
  aria-pressed="false"
  class="button-primary"
  onkeydown="handleKeyPress(event)"
>
  保存
</div>

屏幕阅读器支持

<!-- 纯图标按钮需要 aria-label -->
<button type="button" aria-label="关闭对话框" class="icon-button">
  <svg><!-- close icon --></svg>
</button>

<!-- 加载状态通知 -->
<button 
  type="button" 
  aria-busy="true" 
  aria-live="polite"
  class="button-loading"
>
  <span class="sr-only">正在保存...</span>
  <span aria-hidden="true">保存中</span>
</button>

<!-- 禁用状态说明 -->
<button 
  type="button" 
  disabled 
  aria-describedby="save-disabled-reason"
>
  保存
</button>
<span id="save-disabled-reason" class="sr-only">
  请先填写必填项
</span>

完整组件实现

React 组件

// Button.tsx
import React, { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react';
import clsx from 'clsx';
import styles from './Button.module.scss';

// 类型定义
export type ButtonVariant = 
  | 'primary' 
  | 'secondary' 
  | 'tertiary' 
  | 'danger' 
  | 'ghost';

export type ButtonSize = 'large' | 'medium' | 'small' | 'mini';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** 按钮变体 */
  variant?: ButtonVariant;
  /** 按钮尺寸 */
  size?: ButtonSize;
  /** 是否加载中 */
  loading?: boolean;
  /** 是否块级按钮 */
  block?: boolean;
  /** 圆形按钮(仅图标时使用) */
  circle?: boolean;
  /** 药丸形状 */
  pill?: boolean;
  /** 左侧图标 */
  leftIcon?: ReactNode;
  /** 右侧图标 */
  rightIcon?: ReactNode;
  /** 加载时的文字 */
  loadingText?: string;
}

// 加载动画组件
const Spinner: React.FC<{ size: ButtonSize }> = ({ size }) => (
  <svg 
    className={styles.spinner} 
    viewBox="0 0 24 24"
    style={{ 
      width: size === 'mini' ? 14 : size === 'small' ? 16 : 18,
      height: size === 'mini' ? 14 : size === 'small' ? 16 : 18,
    }}
  >
    <circle
      cx="12"
      cy="12"
      r="10"
      stroke="currentColor"
      strokeWidth="3"
      fill="none"
      strokeLinecap="round"
      strokeDasharray="31.4"
      strokeDashoffset="10"
    />
  </svg>
);

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'medium',
      loading = false,
      block = false,
      circle = false,
      pill = false,
      leftIcon,
      rightIcon,
      loadingText,
      disabled,
      className,
      children,
      ...rest
    },
    ref
  ) => {
    const isDisabled = disabled || loading;

    const buttonClass = clsx(
      styles.button,
      styles[`variant-${variant}`],
      styles[`size-${size}`],
      {
        [styles.loading]: loading,
        [styles.block]: block,
        [styles.circle]: circle,
        [styles.pill]: pill,
        [styles.iconOnly]: !children && (leftIcon || rightIcon),
      },
      className
    );

    return (
      <button
        ref={ref}
        className={buttonClass}
        disabled={isDisabled}
        aria-busy={loading}
        {...rest}
      >
        {/* 加载状态 */}
        {loading && (
          <span className={styles.spinnerWrapper}>
            <Spinner size={size} />
          </span>
        )}
        
        {/* 按钮内容 */}
        <span 
          className={clsx(styles.content, { [styles.hidden]: loading && !loadingText })}
        >
          {leftIcon && !loading && (
            <span className={styles.leftIcon}>{leftIcon}</span>
          )}
          
          {loading && loadingText ? loadingText : children}
          
          {rightIcon && !loading && (
            <span className={styles.rightIcon}>{rightIcon}</span>
          )}
        </span>
      </button>
    );
  }
);

Button.displayName = 'Button';

SCSS 样式

// Button.module.scss
@import '@/styles/tokens';

.button {
  // 基础样式
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: $button-icon-gap;
  
  font-family: inherit;
  font-weight: 500;
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
  
  border: 1px solid transparent;
  border-radius: $button-radius-default;
  
  transition: 
    background-color $button-transition-duration $button-transition-timing,
    border-color $button-transition-duration $button-transition-timing,
    color $button-transition-duration $button-transition-timing,
    box-shadow $button-transition-duration $button-transition-timing,
    transform $button-transition-duration $button-transition-timing;
  
  // 禁用默认样式
  appearance: none;
  -webkit-tap-highlight-color: transparent;
  
  &:focus {
    outline: none;
  }
  
  &:focus-visible {
    box-shadow: 
      0 0 0 $button-focus-ring-offset var(--button-focus-bg, #fff),
      0 0 0 ($button-focus-ring-offset + $button-focus-ring-width) var(--button-focus-ring, $button-focus-ring-color);
  }
  
  &:active:not(:disabled) {
    transform: scale($button-active-scale);
  }
  
  &:disabled {
    cursor: not-allowed;
    opacity: 0.6;
  }
}

// 尺寸变体
.size-large {
  height: $button-height-large;
  padding: 0 $button-padding-large;
  font-size: $button-font-size-large;
  
  &.circle, &.iconOnly {
    width: $button-height-large;
    padding: 0;
  }
}

.size-medium {
  height: $button-height-medium;
  padding: 0 $button-padding-medium;
  font-size: $button-font-size-medium;
  
  &.circle, &.iconOnly {
    width: $button-height-medium;
    padding: 0;
  }
}

.size-small {
  height: $button-height-small;
  padding: 0 $button-padding-small;
  font-size: $button-font-size-small;
  
  &.circle, &.iconOnly {
    width: $button-height-small;
    padding: 0;
  }
}

.size-mini {
  height: $button-height-mini;
  padding: 0 $button-padding-mini;
  font-size: $button-font-size-mini;
  
  &.circle, &.iconOnly {
    width: $button-height-mini;
    padding: 0;
  }
}

// 颜色变体
.variant-primary {
  background-color: $button-primary-bg;
  color: $button-primary-text;
  
  &:hover:not(:disabled) {
    background-color: $button-primary-bg-hover;
  }
  
  &:active:not(:disabled) {
    background-color: $button-primary-bg-active;
  }
  
  &:disabled {
    background-color: $button-disabled-bg;
    color: $button-disabled-text;
  }
}

.variant-secondary {
  background-color: $button-secondary-bg;
  color: $button-secondary-text;
  border-color: $button-secondary-border;
  
  &:hover:not(:disabled) {
    background-color: $button-secondary-bg-hover;
    border-color: $button-secondary-border-hover;
  }
  
  &:active:not(:disabled) {
    background-color: $button-secondary-bg-active;
  }
  
  &:disabled {
    background-color: $button-disabled-bg;
    color: $button-disabled-text;
    border-color: $button-disabled-border;
  }
}

.variant-tertiary {
  background-color: transparent;
  color: $color-brand-600;
  
  &:hover:not(:disabled) {
    background-color: $color-brand-50;
  }
  
  &:active:not(:disabled) {
    background-color: $color-brand-100;
  }
}

.variant-danger {
  background-color: $button-danger-bg;
  color: $button-danger-text;
  
  &:hover:not(:disabled) {
    background-color: $button-danger-bg-hover;
  }
  
  &:active:not(:disabled) {
    background-color: $button-danger-bg-active;
  }
}

.variant-ghost {
  --button-focus-bg: transparent;
  --button-focus-ring: rgba(255, 255, 255, 0.4);
  
  background-color: $button-ghost-bg;
  color: $button-ghost-text;
  border-color: $button-ghost-border;
  
  &:hover:not(:disabled) {
    background-color: $button-ghost-bg-hover;
  }
}

// 形状变体
.pill {
  border-radius: $button-radius-round;
}

.circle {
  border-radius: 50%;
}

// 布局变体
.block {
  display: flex;
  width: 100%;
}

// 加载状态
.loading {
  position: relative;
  pointer-events: none;
}

.spinnerWrapper {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
}

.content {
  display: inline-flex;
  align-items: center;
  gap: inherit;
  
  &.hidden {
    opacity: 0;
  }
}

.spinner {
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

// 图标样式
.leftIcon,
.rightIcon {
  display: inline-flex;
  flex-shrink: 0;
}

按钮组设计

多个按钮组合使用时的设计规范:

按钮组组件

// ButtonGroup.tsx
import React, { Children, cloneElement, isValidElement, ReactElement } from 'react';
import clsx from 'clsx';
import styles from './ButtonGroup.module.scss';
import { ButtonProps } from './Button';

interface ButtonGroupProps {
  /** 子按钮 */
  children: ReactElement<ButtonProps>[];
  /** 是否连接在一起 */
  attached?: boolean;
  /** 方向 */
  direction?: 'horizontal' | 'vertical';
  /** 间距 */
  gap?: number;
}

export const ButtonGroup: React.FC<ButtonGroupProps> = ({
  children,
  attached = false,
  direction = 'horizontal',
  gap = 8,
}) => {
  return (
    <div 
      className={clsx(
        styles.buttonGroup,
        styles[direction],
        { [styles.attached]: attached }
      )}
      style={{ gap: attached ? 0 : gap }}
      role="group"
    >
      {Children.map(children, (child, index) => {
        if (!isValidElement(child)) return child;
        
        return cloneElement(child, {
          className: clsx(
            child.props.className,
            attached && styles.attachedButton,
            attached && index === 0 && styles.first,
            attached && index === children.length - 1 && styles.last,
          ),
        });
      })}
    </div>
  );
};
// ButtonGroup.module.scss
.buttonGroup {
  display: inline-flex;
  
  &.vertical {
    flex-direction: column;
  }
}

.attached {
  .attachedButton {
    border-radius: 0;
    
    // 移除相邻边框重叠
    &:not(:first-child) {
      margin-left: -1px;
    }
    
    &:hover, &:focus {
      z-index: 1;
    }
    
    &.first {
      border-top-left-radius: $button-radius-default;
      border-bottom-left-radius: $button-radius-default;
    }
    
    &.last {
      border-top-right-radius: $button-radius-default;
      border-bottom-right-radius: $button-radius-default;
    }
  }
  
  &.vertical {
    .attachedButton {
      &:not(:first-child) {
        margin-left: 0;
        margin-top: -1px;
      }
      
      &.first {
        border-radius: 0;
        border-top-left-radius: $button-radius-default;
        border-top-right-radius: $button-radius-default;
      }
      
      &.last {
        border-radius: 0;
        border-bottom-left-radius: $button-radius-default;
        border-bottom-right-radius: $button-radius-default;
      }
    }
  }
}

最佳实践

1. 按钮层级

一个页面中应该只有一个主要按钮:

// ✓ 正确:一个主要按钮 + 次要按钮
<div className="actions">
  <Button variant="secondary">取消</Button>
  <Button variant="primary">保存</Button>
</div>

// ✗ 错误:多个主要按钮
<div className="actions">
  <Button variant="primary">保存草稿</Button>
  <Button variant="primary">发布</Button>
</div>

2. 按钮顺序

遵循平台规范的按钮顺序:

  • Windows/Web:取消在左,确认在右
  • macOS/iOS:确认在右(更突出)
// Web 标准顺序
<div className="dialog-actions">
  <Button variant="secondary">取消</Button>
  <Button variant="primary">确认</Button>
</div>

3. 加载状态处理

// 推荐:保持按钮宽度稳定
<Button loading loadingText="保存中...">保存</Button>

// 也可以:使用固定宽度
<Button loading style={{ minWidth: 120 }}>保存</Button>

4. 禁用状态说明

// 提供禁用原因
<Tooltip content="请先填写必填项">
  <Button disabled>提交</Button>
</Tooltip>

总结

构建一个专业的按钮系统需要考虑:

  1. 完整的分类体系:语义、尺寸、状态的全覆盖
  2. 规范的设计令牌:保证视觉一致性和可维护性
  3. 细致的交互状态:每个状态都有明确的视觉反馈
  4. 严格的可访问性:对比度、键盘支持、屏幕阅读器
  5. 灵活的组合使用:支持图标、加载、按钮组等场景

按钮虽小,但设计得好能够显著提升产品的专业度和用户体验。

延伸阅读