前端设计系统搭建:从设计 Token 到组件库

前端设计系统搭建:从设计 Token 到组件库

设计系统(Design System)不是大公司的专利。即使是一个小团队,有一套基本的设计系统也能显著提升开发效率和设计一致性。

设计系统的层次

设计系统
├── 设计 Token(颜色、字体、间距、圆角)
├── 基础组件(Button、Input、Card)
├── 业务组件(SearchBar、PostCard、UserMenu)
└── 模式和规范(布局模式、交互模式、文案规范)

设计 Token

Token 是设计系统的原子单位。用 CSS 变量定义:

:root {
  /* 颜色 */
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-success: #10b981;
  --color-warning: #f59e0b;
  --color-danger: #ef4444;

  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-text-secondary: #6b7280;
  --color-border: #e5e7eb;

  /* 字体 */
  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
  --font-size-xs: 0.75rem;
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;

  /* 间距 */
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-3: 0.75rem;
  --spacing-4: 1rem;
  --spacing-6: 1.5rem;
  --spacing-8: 2rem;

  /* 圆角 */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-full: 9999px;

  /* 阴影 */
  --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

暗色模式只需要切换变量值:

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #1a1a1a;
    --color-text: #f5f5f5;
    --color-text-secondary: #9ca3af;
    --color-border: #374151;
  }
}

基础组件

Button

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
}

const variants = {
  primary: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)]',
  secondary: 'bg-transparent border border-[var(--color-border)] hover:bg-gray-50',
  ghost: 'bg-transparent hover:bg-gray-100',
  danger: 'bg-[var(--color-danger)] text-white hover:bg-red-600',
};

const sizes = {
  sm: 'px-3 py-1.5 text-xs',
  md: 'px-4 py-2 text-sm',
  lg: 'px-6 py-3 text-base',
};

function Button({ variant = 'primary', size = 'md', className = '', children, ...props }: ButtonProps) {
  return (
    <button
      className={`inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] disabled:opacity-50 ${variants[variant]} ${sizes[size]} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

Input

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}

function Input({ label, error, id, ...props }: InputProps) {
  const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');

  return (
    <div>
      {label && (
        <label htmlFor={inputId} className="mb-1 block text-sm text-[var(--color-text-secondary)]">
          {label}
        </label>
      )}
      <input
        id={inputId}
        className={`w-full rounded-[var(--radius-md)] border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] ${
          error ? 'border-[var(--color-danger)]' : 'border-[var(--color-border)]'
        }`}
        aria-invalid={error ? 'true' : undefined}
        aria-describedby={error ? `${inputId}-error` : undefined}
        {...props}
      />
      {error && (
        <p id={`${inputId}-error`} className="mt-1 text-xs text-[var(--color-danger)]" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

业务组件

基础组件是通用的,业务组件则包含具体的业务逻辑:

function PostCard({ post }: { post: Post }) {
  return (
    <article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] p-[var(--spacing-4)] transition-shadow hover:[var(--shadow-md)]">
      {post.coverImage && (
        <img src={post.coverImage} alt={post.title} className="mb-[var(--spacing-3)] w-full rounded-[var(--radius-md)]" />
      )}
      <div className="flex items-center gap-[var(--spacing-2)] mb-[var(--spacing-2)]">
        {post.category && (
          <span className="text-xs text-[var(--color-primary)]">{post.category.name}</span>
        )}
        <time className="text-xs text-[var(--color-text-secondary)]">
          {formatDate(post.publishedAt)}
        </time>
      </div>
      <h3 className="mb-[var(--spacing-2)] text-lg font-semibold text-[var(--color-text)]">
        {post.title}
      </h3>
      {post.excerpt && (
        <p className="text-sm text-[var(--color-text-secondary)] line-clamp-2">
          {post.excerpt}
        </p>
      )}
      <div className="mt-[var(--spacing-3)] flex gap-[var(--spacing-2)]">
        {post.tags?.map((tag) => (
          <span key={tag._id} className="text-xs text-[var(--color-text-secondary)]">
            #{tag.name}
          </span>
        ))}
      </div>
    </article>
  );
}

组件文档

用 Storybook 或者简单的文档页面来展示组件:

// 每个组件都有一个简单的展示页面
export default function ButtonShowcase() {
  return (
    <div className="space-y-4 p-8">
      <h2>Button 组件</h2>
      <div className="flex gap-4">
        <Button variant="primary">主要按钮</Button>
        <Button variant="secondary">次要按钮</Button>
        <Button variant="ghost">幽灵按钮</Button>
        <Button variant="danger">危险按钮</Button>
      </div>
      <div className="flex gap-4">
        <Button size="sm">小号</Button>
        <Button size="md">中号</Button>
        <Button size="lg">大号</Button>
      </div>
      <Button disabled>禁用状态</Button>
    </div>
  );
}

设计系统和 Tailwind

如果你用 Tailwind CSS,设计 Token 可以直接用 Tailwind 的主题配置来管理:

// tailwind.config.js
export default {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        success: 'var(--color-success)',
      },
    },
  },
};

这样 Tailwind 的类名直接引用 CSS 变量,修改 Token 值就能全局生效。

不需要一开始就做大

设计系统是一个渐进的过程:

  1. 先定义颜色、字体、间距等基础 Token
  2. 然后提取最常用的基础组件(Button、Input)
  3. 随着项目发展,逐步添加更多组件
  4. 最后形成文档和规范

不要为了搭设计系统而搭设计系统。当你的项目中有超过 3 个页面使用相似的 UI 模式时,就是提取组件的好时机。

ReactCSSTailwindCSS
返回首页