webroad.online
  1. 1Веб
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Инструменты
  8. 8React
  9. 9Стейт-менеджмент
  10. 10Next.js
  11. 11Формы
  12. 12Данные и бэкенд
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Анимации
  16. 16Тестирование
  17. 17Архитектура
Tailwind CSS · Урок 6 из 6

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.tsx
        • card.tsx
        • badge.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.

Официальные источники

Упражнения

Была ли страница полезной?

Один клик — без регистрации.