Поделиться
Поделиться

Дизайн-система — это не набор красивых компонентов, а общий язык между дизайнерами и разработчиками. Без неё каждый экран — это интерпретация, и со временем продукт расползается визуально. С хорошей дизайн-системой изменение цвета бренда занимает одну строку, а новый экран собирается за часы, а не дни.

Что такое дизайн-токены

Дизайн-токены — именованные переменные, которые хранят примитивные значения дизайна: цвета, размеры, отступы, шрифты. Ключевая идея — два уровня токенов.

Уровень 1: Примитивные токены (Primitive / Core)

Конкретные значения без контекста:

{
  "color": {
    "blue": {
      "50":  "#EFF6FF",
      "100": "#DBEAFE",
      "500": "#3B82F6",
      "600": "#2563EB",
      "900": "#1E3A8A"
    },
    "gray": {
      "50":  "#F9FAFB",
      "100": "#F3F4F6",
      "500": "#6B7280",
      "900": "#111827"
    },
    "red": {
      "500": "#EF4444",
      "600": "#DC2626"
    }
  },
  "spacing": {
    "1": "4px",
    "2": "8px",
    "3": "12px",
    "4": "16px",
    "6": "24px",
    "8": "32px",
    "12": "48px",
    "16": "64px"
  },
  "font-size": {
    "xs": "12px",
    "sm": "14px",
    "base": "16px",
    "lg": "18px",
    "xl": "20px",
    "2xl": "24px",
    "3xl": "30px"
  }
}

Уровень 2: Семантические токены (Semantic / Alias)

Контекстуальные ссылки на примитивы. Именно их использует UI-код:

{
  "color": {
    "background": {
      "primary":   "{color.gray.50}",
      "secondary": "{color.gray.100}",
      "inverse":   "{color.gray.900}"
    },
    "text": {
      "primary":   "{color.gray.900}",
      "secondary": "{color.gray.500}",
      "inverse":   "{color.gray.50}",
      "error":     "{color.red.600}"
    },
    "interactive": {
      "primary":         "{color.blue.500}",
      "primary-hover":   "{color.blue.600}",
      "primary-active":  "{color.blue.700}"
    },
    "border": {
      "default": "{color.gray.200}",
      "focus":   "{color.blue.500}",
      "error":   "{color.red.500}"
    }
  }
}

При переключении темы (dark mode) меняются только семантические токены — примитивы остаются неизменными:

// Dark mode overrides
{
  "color": {
    "background": {
      "primary":   "{color.gray.900}",
      "secondary": "{color.gray.800}"
    },
    "text": {
      "primary":   "{color.gray.50}",
      "secondary": "{color.gray.400}"
    }
  }
}

Генерация CSS-переменных из токенов

Инструмент Style Dictionary от Amazon преобразует JSON-токены в CSS, JS, Swift, Kotlin и другие форматы:

// style-dictionary.config.js
const StyleDictionary = require('style-dictionary');

module.exports = {
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      prefix: 'ds',
      buildPath: 'dist/tokens/',
      files: [{
        destination: 'tokens.css',
        format: 'css/variables',
        options: { outputReferences: true }
      }]
    },
    js: {
      transformGroup: 'js',
      buildPath: 'dist/tokens/',
      files: [{
        destination: 'tokens.js',
        format: 'javascript/es6'
      }]
    }
  }
};

Результат в dist/tokens/tokens.css:

:root {
  --ds-color-blue-500: #3B82F6;
  --ds-color-gray-900: #111827;
  --ds-color-background-primary: var(--ds-color-gray-50);
  --ds-color-text-primary: var(--ds-color-gray-900);
  --ds-color-interactive-primary: var(--ds-color-blue-500);
  --ds-spacing-4: 16px;
  --ds-font-size-base: 16px;
}

API компонентов

Хороший компонент имеет минимальный, предсказуемый API. Плохой — передаёт style везде и имеет 40 пропсов.

Принципы дизайна API компонентов

// Плохо: слишком много пропсов, смешение concerns
<Button
  text="Сохранить"
  textColor="#fff"
  backgroundColor="#3B82F6"
  hoverBackgroundColor="#2563EB"
  paddingX={16}
  paddingY={8}
  borderRadius={6}
  fontSize={14}
  fontWeight={600}
  isLoading={loading}
  loadingText="Загрузка..."
  iconLeft={<SaveIcon />}
  onClick={handleSave}
/>

// Хорошо: семантический API через варианты и размеры
<Button
  variant="primary"    // primary | secondary | ghost | destructive
  size="md"            // sm | md | lg
  loading={loading}
  leftIcon={<SaveIcon />}
  onClick={handleSave}
>
  Сохранить
</Button>

Пример реализации Button с токенами

// components/Button/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';

const buttonVariants = cva(
  // Базовые классы
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--ds-color-border-focus] disabled:opacity-50 disabled:pointer-events-none',
  {
    variants: {
      variant: {
        primary:     'bg-[--ds-color-interactive-primary] text-white hover:bg-[--ds-color-interactive-primary-hover]',
        secondary:   'border border-[--ds-color-border-default] bg-transparent hover:bg-[--ds-color-background-secondary]',
        ghost:       'hover:bg-[--ds-color-background-secondary]',
        destructive: 'bg-red-500 text-white hover:bg-red-600',
      },
      size: {
        sm: 'h-8  px-3 text-sm gap-1.5',
        md: 'h-10 px-4 text-sm gap-2',
        lg: 'h-12 px-6 text-base gap-2',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  }
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  loading?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
  variant, size, loading, leftIcon, rightIcon, children, disabled, className, ...props
}) => (
  <button
    className={buttonVariants({ variant, size, className })}
    disabled={disabled || loading}
    aria-busy={loading}
    {...props}
  >
    {loading ? <Loader2 className="animate-spin" size={16} /> : leftIcon}
    {children}
    {!loading && rightIcon}
  </button>
);

Storybook: документация и тестирование компонентов

Storybook — де-факто стандарт для разработки и документирования UI-компонентов в изоляции.

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { Plus, Trash2 } from 'lucide-react';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
    docs: { description: { component: 'Основная кнопка действия.' } }
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'ghost', 'destructive'],
    },
    size: { control: 'select', options: ['sm', 'md', 'lg'] },
    loading: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: { children: 'Сохранить изменения', variant: 'primary' },
};

export const WithIcon: Story = {
  args: {
    children: 'Создать',
    variant: 'primary',
    leftIcon: <Plus size={16} />,
  },
};

export const Destructive: Story = {
  args: {
    children: 'Удалить',
    variant: 'destructive',
    leftIcon: <Trash2 size={16} />,
  },
};

export const Loading: Story = {
  args: { children: 'Сохранить', loading: true },
};

// Визуальное тестирование всех вариантов
export const AllVariants: Story = {
  render: () => (
    <div className="flex flex-col gap-4">
      {(['primary', 'secondary', 'ghost', 'destructive'] as const).map((v) =>
        (['sm', 'md', 'lg'] as const).map((s) => (
          <Button key={`${v}-${s}`} variant={v} size={s}>{v} {s}</Button>
        ))
      )}
    </div>
  ),
};

Figma Variables и синхронизация с кодом

Figma Variables (появились в 2023) позволяют хранить дизайн-токены прямо в Figma и переключать темы. Ключевой workflow: Figma Variables → JSON → CSS/JS.

Структура Figma Variables

В Figma создаём коллекции:

  • Primitives — базовые значения (цвета, числа)
  • Semantic — алиасы на примитивы, с режимами Light/Dark

Автоматическая синхронизация

Плагин Tokens Studio for Figma (или Variables Import/Export) экспортирует переменные в JSON, который затем обрабатывает Style Dictionary:

# Пакеты для синхронизации
npm install -D style-dictionary @tokens-studio/sd-transformations

# Скрипт синхронизации (package.json)
{
  "scripts": {
    "tokens:build": "style-dictionary build --config style-dictionary.config.js",
    "tokens:watch": "style-dictionary build --watch --config style-dictionary.config.js"
  }
}
// style-dictionary.config.js с Tokens Studio трансформерами
const { registerTransforms } = require('@tokens-studio/sd-transformations');
const StyleDictionary = require('style-dictionary');

registerTransforms(StyleDictionary);

module.exports = {
  source: ['figma-tokens/**/*.json'],
  platforms: {
    css: {
      transforms: [
        'ts/descriptionToComment',
        'ts/size/px',
        'ts/opacity',
        'ts/color/modifiers',
        'name/cti/kebab',
      ],
      buildPath: 'src/styles/tokens/',
      files: [
        { destination: 'light.css', format: 'css/variables', filter: { attributes: { mode: 'light' } } },
        { destination: 'dark.css',  format: 'css/variables', filter: { attributes: { mode: 'dark' } } },
      ],
    },
  },
};

Доступность (Accessibility)

Дизайн-система — правильное место для встраивания доступности: один раз в компоненте, а не на каждом экране.

// Доступный компонент Input
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  hint?: string;
}

export const Input: React.FC<InputProps> = ({ label, error, hint, id, ...props }) => {
  const inputId = id || `input-${Math.random().toString(36).slice(2)}`;
  const errorId = error ? `${inputId}-error` : undefined;
  const hintId  = hint  ? `${inputId}-hint`  : undefined;

  return (
    <div className="flex flex-col gap-1.5">
      <label htmlFor={inputId} className="text-sm font-medium text-[--ds-color-text-primary]">
        {label}
      </label>
      <input
        id={inputId}
        aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
        aria-invalid={error ? 'true' : undefined}
        className={`h-10 px-3 rounded-md border text-sm
          focus:outline-none focus:ring-2 focus:ring-[--ds-color-border-focus]
          ${error
            ? 'border-[--ds-color-border-error] bg-red-50'
            : 'border-[--ds-color-border-default]'
          }`}
        {...props}
      />
      {hint  && <p id={hintId}  className="text-xs text-[--ds-color-text-secondary]">{hint}</p>}
      {error && <p id={errorId} className="text-xs text-[--ds-color-text-error]" role="alert">{error}</p>}
    </div>
  );
};

Чеклист доступности для дизайн-системы

| Компонент | Требования | |---|---| | Все компоненты | Минимальный contrast ratio 4.5:1 для текста | | Интерактивные | Видимый focus indicator, tabIndex управление | | Формы | label + aria-describedby для подсказок и ошибок | | Иконки | aria-hidden="true" если декоративные, alt для смысловых | | Модальные окна | focus trap, aria-modal, Escape для закрытия | | Toast/Alert | role="alert" или role="status" |

shadcn/ui vs MUI vs кастомная система

| Подход | Плюсы | Минусы | Когда использовать | |---|---|---|---| | shadcn/ui | Код в проекте, полный контроль, Tailwind | Нужно собирать самостоятельно | Проекты с Tailwind, когда нужна кастомизация | | Material UI (MUI) | Готово из коробки, богатая экосистема | Тяжёлый, сложная кастомизация | Внутренние инструменты, дашборды | | Кастомная система | Полный контроль, нет лишнего кода | Большие временные затраты | Продукт с уникальным дизайном, масштаб | | Radix UI + CSS | Доступность из коробки, headless | Нужно стилизовать с нуля | База для кастомной системы |

Рекомендация для большинства стартапов: начните с shadcn/ui как основы — вы получаете доступные компоненты, код которых полностью под вашим контролем, и можете постепенно заменять компоненты кастомными по мере роста дизайн-системы.

Итог

Дизайн-система — это инвестиция, которая окупается со второй версией продукта. Двухуровневые токены (примитивы + семантика) дают гибкость для тем. Style Dictionary автоматизирует синхронизацию из Figma в код. Storybook делает компоненты живой документацией. А встроенная доступность с первого дня избавляет от дорогостоящего рефакторинга в будущем.