UI kit: Button, Input, cn()
Структура shared/ui, cn на clsx + tailwind-merge, варианты через cva, нативные props.
Обновлено
Что такое UI kit
UI kit — набор базовых компонентов проекта: Button, Input, Card, Badge, Dialog. Их создают один раз по дизайну и используют везде. Любая новая страница собирается из них, а не стилизуется с нуля.
Что вы получаете: визуальное единообразие, доступность, решённую один раз, и изменения дизайна в одном месте.
Где они лежат — структура папок
src/shared/ui/button.tsxобщие компоненты без бизнес-логикиinput.tsxcard.tsxbadge.tsx
lib/cn.tsхелпер для объединения классов
entities/product/ui/product-card.tsxиспользует Card + Badge из shared/ui
app/globals.cssтокены: цвета, шрифты, шкала текста, @utility
Правило FSD: shared/ui ничего не знает о товарах или пользователях — получает только общие props. Компоненты с бизнес-смыслом (ProductCard) лежат в entities или features и собирают детали из shared/ui.
cn() — центральная деталь
Любой компонент кита получает className снаружи, и его нужно объединить со своими классами. Для этого используют хелпер cn:
// shared/lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))| Деталь | Что решает | Пример |
|---|---|---|
clsx |
классы по условию, игнорирует false / null |
clsx('btn', active && 'active') |
tailwind-merge |
конфликты: побеждает последний класс из той же группы | twMerge('px-2 py-1', 'px-4') → 'py-1 px-4' |
Без tailwind-merge у <Button className="px-6"> будут и px-4 (из компонента), и px-6 — а победителя определит порядок в сгенерированном CSS, а не порядок, в котором вы написали.
(В этом приложении cn — простая версия без tailwind-merge, потому что классы снаружи мы не переопределяем.)
Button — анатомия компонента кита
// shared/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentProps } from 'react'
import { cn } from '../lib/cn'
const button = cva(
'inline-flex items-center justify-center gap-2 rounded-md border font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'border-accent bg-accent text-accent-fg hover:bg-accent-hover',
secondary: 'border-line-strong bg-surface hover:bg-hover',
ghost: 'border-transparent text-muted hover:bg-hover hover:text-fg',
danger: 'border-danger bg-danger text-white hover:opacity-90',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-9 px-4 text-sm', lg: 'h-11 px-5' },
},
defaultVariants: { variant: 'primary', size: 'md' },
},
)
type Props = ComponentProps<'button'> & VariantProps<typeof button>
export function Button({ variant, size, className, type = 'button', ...props }: Props) {
return <button type={type} className={cn(button({ variant, size }), className)} {...props} />
}Что даёт каждое решение:
| Решение | Почему |
|---|---|
ComponentProps<'button'> |
бесплатно получаете disabled, onClick, aria-*, ref (React 19) — это настоящий <button> |
...props в конце |
любой нативный атрибут передаётся дальше |
type = 'button' по умолчанию |
не отправит форму случайно |
className последним в cn |
тот, кто использует компонент, может подправить (mt-4, w-full) |
конечные варианты (primary, ghost) |
не принимаете color="#f00" — дизайн остаётся единым |
focus-visible, disabled в базе |
доступность решена один раз |
Input и Card — тот же паттерн
export function Input({ className, ...props }: ComponentProps<'input'>) {
return (
<input
className={cn(
'h-10 w-full rounded-md border border-line-strong bg-surface px-3 text-sm placeholder:text-muted focus-visible:outline-2 focus-visible:outline-accent aria-invalid:border-danger',
className,
)}
{...props}
/>
)
}
export function Card({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('rounded-xl border border-line bg-surface p-6', className)} {...props} />
}aria-invalid:border-danger — состояние ошибки приходит из ARIA-атрибута, поэтому оно и доступное, и стилизованное.
Сложные компоненты: headless + Tailwind
Для Dialog, Dropdown, Tabs, Tooltip, Combobox — поведения, которое трудно сделать правильно (ловушка фокуса, клавиатура, ARIA), — используют headless-библиотеку (только логика, ноль стилей) и добавляют классы сами:
| Библиотека | Что даёт |
|---|---|
| Radix UI / Base UI | доступные нестилизованные примитивы |
| React Aria (Adobe) | хуки и компоненты, очень строгая доступность |
| shadcn/ui | не библиотека — она копирует в ваш проект компоненты Radix + Tailwind + cva + cn, ровно по паттерну выше |
Коротко
- UI kit = общие компоненты в
shared/ui; бизнес-компоненты собирают их. cn = twMerge(clsx(...)): условия + разрешённые конфликты;classNameвсегда последним.- Расширяйте нативные props, конечные варианты через
cva, доступность в базе; для сложных компонентов — headless + Tailwind.