按钮组件设计详解:从设计原则到完整实现
按钮是用户界面中最基础也最重要的交互元素。一个设计良好的按钮系统能够提升用户体验、保持界面一致性,并降低开发维护成本。本文将从设计原则、视觉规范到代码实现,全面解析如何构建专业级的按钮组件。
按钮的重要性
在任何应用中,按钮都承担着关键职责:
用户行为引导
按钮是用户与系统交互的主要触发点,引导用户完成表单提交、页面跳转、操作确认等关键动作。按钮的设计直接影响用户能否顺利完成任务。
信息层级表达
通过不同的按钮样式(主要、次要、文字、危险等),可以清晰表达操作的重要程度和性质,帮助用户快速理解界面结构。
品牌形象传递
按钮的颜色、形状、动效是品牌视觉语言的重要组成部分,一致的按钮风格能够强化品牌认知。
按钮分类体系
一个完整的按钮系统通常包含以下类型:
按语义分类
| 类型 | 用途 | 视觉特征 | 使用场景 |
|---|---|---|---|
| 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 标准:
| 级别 | 普通文本 | 大文本 |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.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>
总结
构建一个专业的按钮系统需要考虑:
- 完整的分类体系:语义、尺寸、状态的全覆盖
- 规范的设计令牌:保证视觉一致性和可维护性
- 细致的交互状态:每个状态都有明确的视觉反馈
- 严格的可访问性:对比度、键盘支持、屏幕阅读器
- 灵活的组合使用:支持图标、加载、按钮组等场景
按钮虽小,但设计得好能够显著提升产品的专业度和用户体验。
延伸阅读
- Material Design - Buttons - Google 的按钮设计指南
- Apple Human Interface Guidelines - Apple 的按钮规范
- Inclusive Components - Toggle Buttons - 无障碍按钮设计


