原子设计原子组件Skill atomic-design-atoms

这个技能专注于创建原子级UI组件,如按钮、输入框、标签和图标,作为设计系统的最小不可分割构建块。它强调可重用性、无状态性和可访问性,用于构建一致的前端界面。关键词:原子设计,UI组件,前端开发,设计系统,React组件,可访问性,样式令牌。

前端开发 0 次安装 0 次浏览 更新于 3/25/2026

name: atomic-design-atoms user-invocable: false description: 在创建原子级UI组件时使用,如按钮、输入框、标签和图标。设计系统的最小构建块。 allowed-tools:

  • Bash
  • Read
  • Write
  • Edit
  • Glob
  • Grep

原子设计:原子

掌握原子组件的创建——设计系统的基本、不可分割的构建块。原子是最小的功能单元,如果进一步分解会失去意义。

什么是原子?

原子是基本的UI元素,作为设计系统中所有其他内容的基础。它们是:

  • 不可分割的:不能分解为更小的功能单元
  • 可重用的:在应用程序的各种上下文中使用
  • 无状态的:通常由父组件控制
  • 有样式的:实现设计令牌以确保外观一致
  • 可访问的:从一开始就考虑到无障碍性

常见的原子类型

交互式原子

  • 按钮
  • 链接
  • 输入框(文本、复选框、单选按钮、选择框)
  • 切换/开关
  • 滑块

显示原子

  • 排版(标题、段落、标签)
  • 图标
  • 图像/头像
  • 徽章/标签
  • 分隔线
  • 旋转器/加载器

表单原子

  • 输入字段
  • 文本区域
  • 复选框
  • 单选按钮
  • 选择下拉框
  • 标签

按钮原子示例

基本实现

// atoms/Button/Button.tsx
import React from 'react';
import type { ButtonHTMLAttributes } from 'react';
import styles from './Button.module.css';

export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** 视觉样式变体 */
  variant?: ButtonVariant;
  /** 按钮大小 */
  size?: ButtonSize;
  /** 全宽按钮 */
  fullWidth?: boolean;
  /** 加载状态 */
  isLoading?: boolean;
  /** 左侧图标 */
  leftIcon?: React.ReactNode;
  /** 右侧图标 */
  rightIcon?: React.ReactNode;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'md',
      fullWidth = false,
      isLoading = false,
      leftIcon,
      rightIcon,
      disabled,
      children,
      className,
      ...props
    },
    ref
  ) => {
    const classNames = [
      styles.button,
      styles[variant],
      styles[size],
      fullWidth && styles.fullWidth,
      isLoading && styles.loading,
      className,
    ]
      .filter(Boolean)
      .join(' ');

    return (
      <button
        ref={ref}
        className={classNames}
        disabled={disabled || isLoading}
        aria-busy={isLoading}
        {...props}
      >
        {isLoading ? (
          <span className={styles.spinner} aria-hidden="true" />
        ) : (
          <>
            {leftIcon && <span className={styles.leftIcon}>{leftIcon}</span>}
            <span className={styles.content}>{children}</span>
            {rightIcon && <span className={styles.rightIcon}>{rightIcon}</span>}
          </>
        )}
      </button>
    );
  }
);

Button.displayName = 'Button';

按钮样式

/* atoms/Button/Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  border-radius: 6px;
  font-weight: 500;
  cursor: pointer;
  transition: all 150ms ease-in-out;
  text-decoration: none;
}

.button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 变体 */
.primary {
  background-color: var(--color-primary-500);
  color: var(--color-white);
}

.primary:hover:not(:disabled) {
  background-color: var(--color-primary-600);
}

.secondary {
  background-color: transparent;
  color: var(--color-primary-500);
  border: 1px solid var(--color-primary-500);
}

.secondary:hover:not(:disabled) {
  background-color: var(--color-primary-50);
}

.tertiary {
  background-color: transparent;
  color: var(--color-primary-500);
}

.tertiary:hover:not(:disabled) {
  background-color: var(--color-primary-50);
}

.danger {
  background-color: var(--color-danger-500);
  color: var(--color-white);
}

.danger:hover:not(:disabled) {
  background-color: var(--color-danger-600);
}

/* 大小 */
.sm {
  padding: 6px 12px;
  font-size: 14px;
  min-height: 32px;
}

.md {
  padding: 8px 16px;
  font-size: 16px;
  min-height: 40px;
}

.lg {
  padding: 12px 24px;
  font-size: 18px;
  min-height: 48px;
}

/* 修饰符 */
.fullWidth {
  width: 100%;
}

.loading {
  position: relative;
  color: transparent;
}

.spinner {
  position: absolute;
  width: 16px;
  height: 16px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.75s linear infinite;
}

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

/* 图标间距 */
.leftIcon,
.rightIcon {
  display: flex;
  align-items: center;
}

输入框原子示例

// atoms/Input/Input.tsx
import React from 'react';
import type { InputHTMLAttributes } from 'react';
import styles from './Input.module.css';

export type InputSize = 'sm' | 'md' | 'lg';

export interface InputProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  /** 大小变体 */
  size?: InputSize;
  /** 错误状态 */
  hasError?: boolean;
  /** 左侧附加元素 */
  leftAddon?: React.ReactNode;
  /** 右侧附加元素 */
  rightAddon?: React.ReactNode;
}

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  (
    {
      size = 'md',
      hasError = false,
      leftAddon,
      rightAddon,
      disabled,
      className,
      ...props
    },
    ref
  ) => {
    const wrapperClasses = [
      styles.wrapper,
      styles[size],
      hasError && styles.error,
      disabled && styles.disabled,
      className,
    ]
      .filter(Boolean)
      .join(' ');

    return (
      <div className={wrapperClasses}>
        {leftAddon && <span className={styles.leftAddon}>{leftAddon}</span>}
        <input
          ref={ref}
          className={styles.input}
          disabled={disabled}
          aria-invalid={hasError}
          {...props}
        />
        {rightAddon && <span className={styles.rightAddon}>{rightAddon}</span>}
      </div>
    );
  }
);

Input.displayName = 'Input';
/* atoms/Input/Input.module.css */
.wrapper {
  display: flex;
  align-items: center;
  border: 1px solid var(--color-neutral-300);
  border-radius: 6px;
  background-color: var(--color-white);
  transition: border-color 150ms, box-shadow 150ms;
}

.wrapper:focus-within {
  border-color: var(--color-primary-500);
  box-shadow: 0 0 0 3px var(--color-primary-100);
}

.input {
  flex: 1;
  border: none;
  background: transparent;
  outline: none;
  width: 100%;
}

.input::placeholder {
  color: var(--color-neutral-400);
}

/* 错误状态 */
.error {
  border-color: var(--color-danger-500);
}

.error:focus-within {
  border-color: var(--color-danger-500);
  box-shadow: 0 0 0 3px var(--color-danger-100);
}

/* 禁用状态 */
.disabled {
  background-color: var(--color-neutral-100);
  cursor: not-allowed;
}

.disabled .input {
  cursor: not-allowed;
}

/* 大小 */
.sm {
  min-height: 32px;
}

.sm .input {
  padding: 6px 12px;
  font-size: 14px;
}

.md {
  min-height: 40px;
}

.md .input {
  padding: 8px 12px;
  font-size: 16px;
}

.lg {
  min-height: 48px;
}

.lg .input {
  padding: 12px 16px;
  font-size: 18px;
}

/* 附加元素 */
.leftAddon,
.rightAddon {
  display: flex;
  align-items: center;
  padding: 0 12px;
  color: var(--color-neutral-500);
}

标签原子示例

// atoms/Label/Label.tsx
import React from 'react';
import type { LabelHTMLAttributes } from 'react';
import styles from './Label.module.css';

export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
  /** 表示必填字段 */
  required?: boolean;
  /** 禁用状态样式 */
  disabled?: boolean;
}

export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
  ({ required = false, disabled = false, children, className, ...props }, ref) => {
    const classNames = [
      styles.label,
      disabled && styles.disabled,
      className,
    ]
      .filter(Boolean)
      .join(' ');

    return (
      <label ref={ref} className={classNames} {...props}>
        {children}
        {required && (
          <span className={styles.required} aria-hidden="true">
            *
          </span>
        )}
      </label>
    );
  }
);

Label.displayName = 'Label';

图标原子示例

// atoms/Icon/Icon.tsx
import React from 'react';

export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';

const sizeMap: Record<IconSize, number> = {
  xs: 12,
  sm: 16,
  md: 20,
  lg: 24,
  xl: 32,
};

export interface IconProps extends React.SVGAttributes<SVGElement> {
  /** 图标名称/标识符 */
  name: string;
  /** 图标大小 */
  size?: IconSize;
  /** 自定义颜色 */
  color?: string;
  /** 可访问标签 */
  label?: string;
}

export const Icon: React.FC<IconProps> = ({
  name,
  size = 'md',
  color = 'currentColor',
  label,
  className,
  ...props
}) => {
  const pixelSize = sizeMap[size];

  return (
    <svg
      className={className}
      width={pixelSize}
      height={pixelSize}
      fill={color}
      aria-label={label}
      aria-hidden={!label}
      role={label ? 'img' : 'presentation'}
      {...props}
    >
      <use href={`/icons.svg#${name}`} />
    </svg>
  );
};

Icon.displayName = 'Icon';

头像原子示例

// atoms/Avatar/Avatar.tsx
import React from 'react';
import styles from './Avatar.module.css';

export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';

export interface AvatarProps {
  /** 图像源URL */
  src?: string;
  /** 图像替代文本 */
  alt: string;
  /** 后备首字母 */
  initials?: string;
  /** 大小变体 */
  size?: AvatarSize;
  /** 附加类名 */
  className?: string;
}

export const Avatar: React.FC<AvatarProps> = ({
  src,
  alt,
  initials,
  size = 'md',
  className,
}) => {
  const [imageError, setImageError] = React.useState(false);

  const classNames = [styles.avatar, styles[size], className]
    .filter(Boolean)
    .join(' ');

  const showImage = src && !imageError;
  const showInitials = !showImage && initials;

  return (
    <div className={classNames} role="img" aria-label={alt}>
      {showImage && (
        <img
          src={src}
          alt={alt}
          className={styles.image}
          onError={() => setImageError(true)}
        />
      )}
      {showInitials && (
        <span className={styles.initials} aria-hidden="true">
          {initials}
        </span>
      )}
      {!showImage && !showInitials && (
        <span className={styles.placeholder} aria-hidden="true">
          ?
        </span>
      )}
    </div>
  );
};

Avatar.displayName = 'Avatar';

徽章原子示例

// atoms/Badge/Badge.tsx
import React from 'react';
import styles from './Badge.module.css';

export type BadgeVariant =
  | 'default'
  | 'primary'
  | 'success'
  | 'warning'
  | 'danger'
  | 'info';

export type BadgeSize = 'sm' | 'md';

export interface BadgeProps {
  /** 视觉变体 */
  variant?: BadgeVariant;
  /** 大小变体 */
  size?: BadgeSize;
  /** 徽章内容 */
  children: React.ReactNode;
  /** 附加类名 */
  className?: string;
}

export const Badge: React.FC<BadgeProps> = ({
  variant = 'default',
  size = 'md',
  children,
  className,
}) => {
  const classNames = [styles.badge, styles[variant], styles[size], className]
    .filter(Boolean)
    .join(' ');

  return <span className={classNames}>{children}</span>;
};

Badge.displayName = 'Badge';

复选框原子示例

// atoms/Checkbox/Checkbox.tsx
import React from 'react';
import type { InputHTMLAttributes } from 'react';
import styles from './Checkbox.module.css';

export interface CheckboxProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
  /** 不确定状态 */
  indeterminate?: boolean;
  /** 标签文本 */
  label?: string;
}

export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
  ({ indeterminate = false, label, disabled, className, ...props }, ref) => {
    const inputRef = React.useRef<HTMLInputElement>(null);

    React.useImperativeHandle(ref, () => inputRef.current!);

    React.useEffect(() => {
      if (inputRef.current) {
        inputRef.current.indeterminate = indeterminate;
      }
    }, [indeterminate]);

    const wrapperClasses = [
      styles.wrapper,
      disabled && styles.disabled,
      className,
    ]
      .filter(Boolean)
      .join(' ');

    const checkbox = (
      <span className={styles.checkbox}>
        <input
          ref={inputRef}
          type="checkbox"
          className={styles.input}
          disabled={disabled}
          {...props}
        />
        <span className={styles.control} aria-hidden="true">
          <svg className={styles.check} viewBox="0 0 12 10">
            <polyline points="1.5 6 4.5 9 10.5 1" />
          </svg>
          <svg className={styles.indeterminate} viewBox="0 0 12 2">
            <line x1="1" y1="1" x2="11" y2="1" />
          </svg>
        </span>
      </span>
    );

    if (label) {
      return (
        <label className={wrapperClasses}>
          {checkbox}
          <span className={styles.label}>{label}</span>
        </label>
      );
    }

    return checkbox;
  }
);

Checkbox.displayName = 'Checkbox';

排版原子

// atoms/Typography/Text.tsx
import React from 'react';
import styles from './Typography.module.css';

export type TextSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type TextWeight = 'normal' | 'medium' | 'semibold' | 'bold';
export type TextColor = 'default' | 'muted' | 'primary' | 'success' | 'danger';

export interface TextProps {
  as?: 'p' | 'span' | 'div';
  size?: TextSize;
  weight?: TextWeight;
  color?: TextColor;
  truncate?: boolean;
  children: React.ReactNode;
  className?: string;
}

export const Text: React.FC<TextProps> = ({
  as: Component = 'p',
  size = 'md',
  weight = 'normal',
  color = 'default',
  truncate = false,
  children,
  className,
}) => {
  const classNames = [
    styles.text,
    styles[`size-${size}`],
    styles[`weight-${weight}`],
    styles[`color-${color}`],
    truncate && styles.truncate,
    className,
  ]
    .filter(Boolean)
    .join(' ');

  return <Component className={classNames}>{children}</Component>;
};

// atoms/Typography/Heading.tsx
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;

export interface HeadingProps {
  level: HeadingLevel;
  as?: `h${HeadingLevel}`;
  children: React.ReactNode;
  className?: string;
}

export const Heading: React.FC<HeadingProps> = ({
  level,
  as,
  children,
  className,
}) => {
  const Component = as || (`h${level}` as const);
  const classNames = [styles.heading, styles[`h${level}`], className]
    .filter(Boolean)
    .join(' ');

  return <Component className={classNames}>{children}</Component>;
};

最佳实践

1. 使用 forwardRef 进行 DOM 访问

// 好的:允许父级访问DOM节点
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  (props, ref) => <input ref={ref} {...props} />
);

// 不好的:父级无法访问DOM
export const Input = (props: InputProps) => <input {...props} />;

2. 扩展原生 HTML 属性

// 好的:支持所有原生按钮属性
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
}

// 不好的:缺少原生属性
interface ButtonProps {
  onClick?: () => void;
  disabled?: boolean;
}

3. 提供合理的默认值

// 好的:开箱即用
export const Button = ({
  variant = 'primary',
  size = 'md',
  type = 'button', // 防止意外表单提交
  ...props
}) => { ... };

// 不好的:需要显式属性
export const Button = ({ variant, size, ...props }) => { ... };

4. 保持原子仅用于展示

// 好的:没有业务逻辑
const Button = ({ onClick, children }) => (
  <button onClick={onClick}>{children}</button>
);

// 不好的:原子有API调用
const SubmitButton = () => {
  const handleClick = async () => {
    await api.submit(); // 业务逻辑在原子中!
  };
  return <button onClick={handleClick}>提交</button>;
};

需要避免的反模式

1. 具有内部状态的原子

// 不好的:原子管理自己的状态
const Input = () => {
  const [value, setValue] = useState('');
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};

// 好的:由父级控制
const Input = ({ value, onChange }) => (
  <input value={value} onChange={onChange} />
);

2. 具有复杂逻辑的原子

// 不好的:原子中有复杂验证
const EmailInput = ({ value, onChange }) => {
  const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  return <input value={value} className={isValid ? '' : 'error'} />;
};

// 好的:验证由父级/分子处理
const Input = ({ value, onChange, hasError }) => (
  <input value={value} className={hasError ? 'error' : ''} />
);

3. 硬编码样式

// 不好的:硬编码颜色
const Button = () => (
  <button style={{ backgroundColor: '#2196f3' }}>点击</button>
);

// 好的:使用设计令牌
const Button = () => (
  <button style={{ backgroundColor: 'var(--color-primary-500)' }}>
    点击
  </button>
);

何时使用此技能

  • 创建新的基本UI组件
  • 重构现有组件为原子
  • 构建设计系统基础
  • 确保组件一致性
  • 提高组件可重用性

相关技能

  • atomic-design-fundamentals - 核心方法概述
  • atomic-design-molecules - 将原子组合成分子