UI Kit и дизайн-токены
Из M2: мы заложили FSD, где
shared— слой переиспользуемых компонентов. Теперь строим настоящий UI kit: дизайн-токены, базовые компоненты, документация. Структура — с прицелом на вынесение в отдельный пакет.
Перед началом: Модули 1–10. Инструменты: CSS Variables, Storybook. Время: ~8 ч.
Канон. Полная дисциплина UI Kit — публикация npm-пакета,
exports-карта,size-limit, Storybook-галерея как единственный канон, измеримые KPI — в курсе «UI Kit и мессенджер». Здесь — рабочий минимум, которого достаточно для этого приложения и который не противоречит канону: токены → примитивы → составные компоненты, нейтральность компонентов, слойshared.
🔥 Инцидент. В чате 5 видов кнопок, 3 цвета primary, 2 шрифта. Дизайнер просит «сделать как в макете», но макет обновился, а код — нет. Нужен UI Kit: токены (colors, spacing, typography) → примитивы (Button, Input, Avatar) → молекулы (ChatMessage, InputPanel). Единый источник правды для всей команды.
📖 Теория
Зачем нужен UI kit
Без UI kit: каждый компонент пишет <input className="input"> или <button className="btn-primary">.
Стиили дублируются, компоненты выглядят по-разному, переиспользовать между проектами невозможно.
С UI kit: стандартизированные <Button>, <Input>, <Loader>, <Toast>, основанные на дизайн-токенах.
Один источник правды — переиспользуется в любом проекте.
Дизайн-токены
Базовый слой абстракции: цвет, типографика, отступы, скругления — как CSS-переменные.
css/* shared/ui/tokens.css — единый источник дизайн-системы */ :root { /* Colors */ --c-bg: #0d1117; --c-surface: #161b22; --c-surface-2: #21262d; --c-text: #c9d1d9; --c-text-muted: #8b949e; --c-primary: #2563eb; --c-primary-hover: #1d4ed8; --c-danger: #f85149; --c-success: #3fb950; --c-border: #30363d; /* Typography */ --f-size-sm: 0.85rem; --f-size-md: 1rem; --f-size-lg: 1.25rem; --f-family: system-ui, -apple-system, sans-serif; /* Spacing */ --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-6: 24px; --s-8: 32px; /* Radius */ --r-sm: 6px; --r-md: 8px; --r-lg: 12px; --r-full: 9999px; /* Shadows */ --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-md: 0 4px 12px rgba(0,0,0,0.12); }
Эти токены — уже по сути есть в
styles.cssпроекта (как--bg,--surface,--accent). UI kit формализует их и делает переиспользуемыми.
Структура UI kit в FSD
src/shared/
├── ui/ ← переиспользуемые компоненты
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.module.css
│ │ └── index.ts ← публичный API
│ ├── Input/
│ │ ├── Input.tsx
│ │ ├── Input.module.css
│ │ └── index.ts
│ ├── Loader/
│ │ └── ...
│ ├── Toast/
│ │ └── ...
│ ├── Badge/
│ │ └── ...
│ └── index.ts ← barrel: экспортирует все компоненты
├── tokens.css ← дизайн-токены
└── index.ts ← публичный API всего shared
Каждый компонент:
- Имеет
index.ts(публичный API) — другие слои импортируют только отсюда. - Не зависит от бизнес-логики, стора, API.
- Принимает стандартные props (
variant,size,disabled,className,children).
Базовые компоненты
Button
tsx// shared/ui/Button/Button.tsx import { type ButtonHTMLAttributes } from "react" import styles from "./Button.module.css" type Variant = "primary" | "secondary" | "danger" | "ghost" type Size = "sm" | "md" | "lg" interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant size?: Size loading?: boolean } export function Button({ variant = "primary", size = "md", loading = false, disabled, children, className, ...rest }: ButtonProps) { return ( <button className={[styles.button, styles[variant], styles[size], className] .filter(Boolean).join(" ")} disabled={disabled || loading} {...rest} > {loading ? <Loader size="sm" /> : children} </button> ) }
css/* shared/ui/Button/Button.module.css */ .button { border: none; border-radius: var(--r-md); cursor: pointer; font-family: inherit; font-weight: 500; transition: all 0.2s; } .button:disabled { opacity: 0.4; cursor: not-allowed; } .primary { background: var(--c-primary); color: #fff; } .primary:hover:not(:disabled) { background: var(--c-primary-hover); } .secondary { background: var(--c-surface-2); color: var(--c-text); } .danger { background: var(--c-danger); color: #fff; } .ghost { background: transparent; color: var(--c-text-muted); } .sm { padding: var(--s-1) var(--s-2); font-size: var(--f-size-sm); } .md { padding: var(--s-2) var(--s-4); font-size: var(--f-size-md); } .lg { padding: var(--s-3) var(--s-6); font-size: var(--f-size-lg); }
Input
tsx// shared/ui/Input/Input.tsx import { type InputHTMLAttributes, forwardRef } from "react" import styles from "./Input.module.css" interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string error?: string } export const Input = forwardRef<HTMLInputElement, InputProps>( ({ label, error, className, ...rest }, ref) => ( <div className={styles.wrapper}> {label && <label className={styles.label}>{label}</label>} <input ref={ref} className={[styles.input, error && styles.error, className] .filter(Boolean).join(" ")} {...rest} /> {error && <span className={styles.errorText}>{error}</span>} </div> ) )
Loader
tsx// shared/ui/Loader/Loader.tsx import styles from "./Loader.module.css" export function Loader({ size = "md" }: { size?: "sm" | "md" | "lg" }) { return <div className={[styles.spinner, styles[size]].join(" ")} /> }
css/* shared/ui/Loader/Loader.module.css */ .spinner { border: 2px solid var(--c-border); border-top-color: var(--c-primary); border-radius: 50%; animation: spin 0.6s linear infinite; } .sm { width: 16px; height: 16px; } .md { width: 24px; height: 24px; } .lg { width: 40px; height: 40px; } @keyframes spin { to { transform: rotate(360deg); } }
Toast (уведомления)
tsx// shared/ui/Toast/Toast.tsx import { create } from "zustand" import styles from "./Toast.module.css" type ToastType = "success" | "error" | "info" interface ToastItem { id: string; type: ToastType; message: string } // Стор для тостов (можно использовать без prop drilling) const useToastStore = create<{ toasts: ToastItem[]; add: (t: ToastItem) => void; remove: (id: string) => void }>((set) => ({ toasts: [], add: (t) => set((s) => ({ toasts: [...s.toasts, t] })), remove: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), })) export const toast = { success: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "success", message: msg }), error: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "error", message: msg }), info: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "info", message: msg }), } export function ToastContainer() { const { toasts, remove } = useToastStore() return ( <div className={styles.container}> {toasts.map((t) => ( <div key={t.id} className={[styles.toast, styles[t.type]].join(" ")} onClick={() => remove(t.id)}> {t.message} </div> ))} </div> ) }
Barrel export (публичный API)
ts// shared/ui/index.ts export { Button } from "./Button" export { Input } from "./Input" export { Loader } from "./Loader" export { Badge } from "./Badge" export { ToastContainer, toast } from "./Toast"
Использование в компонентах:
tsx// features/auth/ui/LoginForm.tsx import { Button, Input, toast } from "@/shared/ui" function LoginForm() { return ( <form onSubmit={handleSubmit}> <Input label="Email" type="email" value={email} onChange={...} /> <Input label="Пароль" type="password" value={password} onChange={...} error={error} /> <Button type="submit" loading={isLoading}>Войти</Button> </form> ) }
Раньше:
<input className="input" />+<button className="btn-primary" />— raw HTML. Теперь:<Input label="..." error="..." />+<Button loading />— типизированные компоненты.
Autocomplete (компонент автокомплита)
Автокомплит — composite-компонент: Input + debounce + список результатов + клавиатурная навигация.
Это самый полезный UI kit компонент для чата (поиск чатов, пригласить пользователя).
tsx// shared/ui/Autocomplete/Autocomplete.tsx import { useState, useEffect, useRef, useCallback } from "react" import { Input } from "../Input" import { Loader } from "../Loader" import styles from "./Autocomplete.module.css" interface SearchOptions { signal?: AbortSignal } interface AutocompleteProps<T> { value: string onChange: (value: string) => void onSearch: (query: string, options?: SearchOptions) => Promise<T[]> onSelect: (item: T) => void renderItem: (item: T, query: string) => React.ReactNode getKey: (item: T) => string placeholder?: string label?: string debounceMs?: number minQueryLength?: number emptyText?: string loadingText?: string } export function Autocomplete<T>({ value, onChange, onSearch, onSelect, renderItem, getKey, placeholder, label, debounceMs = 300, minQueryLength = 2, emptyText = "Начните вводить для поиска", loadingText = "Поиск...", }: AutocompleteProps<T>) { const [results, setResults] = useState<T[]>([]) const [isLoading, setIsLoading] = useState(false) const [isOpen, setIsOpen] = useState(false) const [highlightIndex, setHighlightIndex] = useState(-1) const [error, setError] = useState("") const requestIdRef = useRef(0) const containerRef = useRef<HTMLDivElement>(null) const abortRef = useRef<AbortController | null>(null) // Debounced search useEffect(() => { if (value.trim().length < minQueryLength) { setResults([]) setIsOpen(false) return } // Abort previous in-flight request (user typed again before it returned) if (abortRef.current) abortRef.current.abort() const controller = new AbortController() const signal = controller.signal const reqId = ++requestIdRef.current setIsLoading(true) setError("") const timer = setTimeout(async () => { try { const items = await onSearch(value, { signal }) if (reqId !== requestIdRef.current) return setResults(items) setIsOpen(true) setHighlightIndex(-1) } catch (e) { if (signal.aborted) return // Request cancelled by AbortController, not an error if (reqId !== requestIdRef.current) return // Obsolete request setError(e instanceof Error ? e.message : "Ошибка поиска") setResults([]) } finally { if (reqId === requestIdRef.current) setIsLoading(false) } }, debounceMs) return () => { clearTimeout(timer) controller.abort() // Cleanup: cancel request on unmount or value change } }, [value, debounceMs, minQueryLength, onSearch]) // Click outside → close useEffect(() => { const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false) if (abortRef.current) abortRef.current.abort() } } document.addEventListener("mousedown", handler) return () => document.removeEventListener("mousedown", handler) }, []) // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (!isOpen || results.length === 0) return if (e.key === "ArrowDown") { e.preventDefault() setHighlightIndex((i) => Math.min(i + 1, results.length - 1)) } else if (e.key === "ArrowUp") { e.preventDefault() setHighlightIndex((i) => Math.max(i - 1, 0)) } else if (e.key === "Enter" && highlightIndex >= 0) { e.preventDefault() handleSelect(results[highlightIndex]) } else if (e.key === "Escape") { setIsOpen(false) setHighlightIndex(-1) } }, [isOpen, results, highlightIndex]) const handleSelect = (item: T) => { onSelect(item) setIsOpen(false) setHighlightIndex(-1) } return ( <div ref={containerRef} className={styles.wrapper}> <Input label={label} value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => results.length > 0 && setIsOpen(true)} placeholder={placeholder} error={error} /> {isOpen && ( <div className={styles.dropdown}> {isLoading ? ( <div className={styles.state}><Loader size="sm" /> {loadingText}</div> ) : results.length === 0 ? ( <div className={styles.state}>{emptyText}</div> ) : ( results.map((item, i) => ( <div key={getKey(item)} className={[styles.item, i === highlightIndex && styles.highlighted] .filter(Boolean).join(" ")} onMouseDown={(e) => { e.preventDefault(); handleSelect(item) }} onMouseEnter={() => setHighlightIndex(i)} > {renderItem(item, value)} </div> )) )} </div> )} </div> ) }
css/* shared/ui/Autocomplete/Autocomplete.module.css */ .wrapper { position: relative; } .dropdown { position: absolute; top: 100%; left: 0; right: 0; margin-top: var(--s-1); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--r-md); box-shadow: var(--shadow-md); max-height: 320px; overflow-y: auto; z-index: 100; } .state { padding: var(--s-3) var(--s-4); color: var(--c-text-muted); font-size: var(--f-size-sm); display: flex; align-items: center; gap: var(--s-2); } .item { padding: var(--s-2) var(--s-4); cursor: pointer; font-size: var(--f-size-md); transition: background 0.1s; } .item:hover, .highlighted { background: var(--c-surface-2); }
Использование в чате (поиск комнат)
tsximport { Autocomplete, Badge } from "@/shared/ui" function ChatSearch({ onSelectRoom }: { onSelectRoom: (room: Room) => void }) { return ( <Autocomplete<Room> value={query} onChange={setQuery} onSearch={async (q, { signal }) => { const res = await fetch(`/api/rooms/search?q=${q}&limit=10`, { signal }) return res.json() }} onSelect={onSelectRoom} getKey={(room) => room.id} renderItem={(room, query) => ( <div style={{ display: "flex", justifyContent: "space-between" }}> <span>{highlightPrefix(room.name, query)}</span> <Badge>{room.memberCount} участников</Badge> </div> )} placeholder="Поиск чата..." label="Поиск" debounceMs={300} minQueryLength={2} emptyText="Ничего не найдено" /> ) } // Подсветка совпадений (из курса алгоритмов — Trie + prefix highlight) function highlightPrefix(text: string, prefix: string) { const idx = text.toLowerCase().indexOf(prefix.toLowerCase()) if (idx < 0) return text return (<>{text.slice(0, idx)}<mark>{text.slice(idx, idx + prefix.length)}</mark>{text.slice(idx + prefix.length)}</>) }
Autocomplete — чеклист
- Debounce (встроенный, не нужен внешний хук)
- Race condition protection (requestId — только последний запрос показывает)
- AbortController — отмена in-flight запроса при новом вводе, размонтировании, click outside
- Клавиатурная навигация (Arrow Up/Down, Enter, Escape)
- Click outside → close
- Loading state (Loader внутри)
- Empty state (текст)
- Error state (передаётся в Input.error)
- Подсветка prefix в результатах
- Generic
<T>— работает с любым типом данных - minQueryLength (не искать при < 2 символов — guard на UI уровне)
Связь с курсом алгоритмов: Autocomplete — идеальное место для применения Trie (F1 bài Search & Autocomplete). Вместо
fetchна каждый запрос — Trie на клиенте для мгновенного поиска по локальному кэшу,fetchтолько при промахе кэша.
Вынесение в отдельный пакет
Когда UI kit стабилен — вынести в npm-пакет, чтобы переиспользовать в разных проектах:
Структура пакета
@myorg/ui-kit/
├── src/
│ ├── Button/
│ ├── Input/
│ ├── Loader/
│ ├── Toast/
│ ├── tokens.css
│ └── index.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts ← сборщик (esm + cjs + dts)
package.json
json{ "name": "@myorg/ui-kit", "version": "0.1.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }, "./tokens.css": "./dist/tokens.css" }, "scripts": { "build": "tsup src/index.ts --format esm --dts --external react", "dev": "tsup src/index.ts --format esm --dts --external react --watch" }, "peerDependencies": { "react": "^19.0.0" } }
Использование в другом проекте
bashnpm install @myorg/ui-kit
tsximport { Button, Input, toast } from "@myorg/ui-kit" import "@myorg/ui-kit/tokens.css" function App() { return <Button variant="primary" onClick={() => toast.success("Привет!")}> Жми </Button> }
tsup.config.ts
tsimport { defineConfig } from "tsup" export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], dts: true, external: ["react", "react-dom"], loader: { ".css": "copy" }, })
Рефакторинг существующих компонентов
После создания UI kit — заменить raw HTML в компонентах:
tsx// Было (Login.tsx): <input className="input" type="email" placeholder="Email" /> <button className="btn-primary">Войти</button> // Стало: <Input type="email" placeholder="Email" label="Email" /> <Button type="submit" loading={isLoading}>Войти</Button>
Чеклист UI kit
- Дизайн-токены (tokens.css) — единый источник цветов, отступов, типографики
- Button (variants: primary/secondary/danger/ghost, sizes: sm/md/lg, loading)
- Input (label, error, forwardRef — для react-hook-form совместимости)
- Loader (sizes: sm/md/lg)
- Badge (для тегов, статусов)
- Toast (store-based, без prop drilling)
- Barrel export (
shared/ui/index.ts) - CSS Modules (изолированные стили, не глобальные классы)
- Storybook (опционально — документация компонентов)
- package.json + tsup (готовность к выносу в пакет)
Практика
- Создать
Button,Input,Loaderпо дизайн-токенам. - Рефакторить
LoginFormиChatRoom— заменить raw HTML на UI kit компоненты. - Создать
ToastContainer— добавить вApp.tsx. - Проверить тёмную тему: токены меняются → все компоненты адаптируются.
- (Опц.) Настроить
tsupсборку →npm pack→ установить в другой проект.
Результат: UI kit в
shared/ui/— переиспользуется внутри проекта и готов к выносу в отдельный npm-пакет@myorg/ui-kit. Дизайн-токены — единый источник правды. Тёмная тема работает автоматически (меняются только CSS-переменные).
💱 Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
| Дизайн-токены (CSS Variables) | Единый источник цветов/отступов, авто-темизация | Нужна дисциплина — не хардкодить значения |
UI kit в shared/ui (in-repo) | Быстрые итерации, нет версионирования | Расползается между проектами при копировании |
Вынос в npm-пакет (@myorg/ui-kit) | Versioning, переиспользование, изоляция | Сборщик (tsup), peerDeps, релизные накладные |
| CSS Modules | Изоляция стилей, tree-shaking | Динамическая темизация сложнее, чем CSS-in-JS |
| Storybook | Каталог компонентов, visual-regression | Отдельная инфраструктура, замедляет CI |
🧩 Проверь себя
🎓 Мостик в Реальный мир
🎓 Вы построили фронтенд мессенджера: React + FSD + Zustand/Effector + PWA + Push + тесты + деплой + UI Kit. Система готова к продакшену.