Дизайн-система — это не набор красивых компонентов, а общий язык между дизайнерами и разработчиками. Без неё каждый экран — это интерпретация, и со временем продукт расползается визуально. С хорошей дизайн-системой изменение цвета бренда занимает одну строку, а новый экран собирается за часы, а не дни.
Что такое дизайн-токены
Дизайн-токены — именованные переменные, которые хранят примитивные значения дизайна: цвета, размеры, отступы, шрифты. Ключевая идея — два уровня токенов.
Уровень 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 делает компоненты живой документацией. А встроенная доступность с первого дня избавляет от дорогостоящего рефакторинга в будущем.

