Урок 15. Теория: компоненты вместо CSS-классов

📁 Раздел: Организация кода ⏱️ Время изучения: ~55 мин 🎯 Сложность: Средняя

⚡ Кратко

Правило трёх: третье повторение одинакового набора классов — сигнал сделать компонент. Компонент, а не CSS-класс: у компонента есть варианты, типы и одно место для правки. @apply оставьте для редких случаев, когда разметку менять нельзя. Конфликты классов разрешает tailwind-merge, варианты описывает cva.

Когда извлекать

Не спешите. Пока набор классов встретился один-два раза, дублирование дешевле абстракции: его видно, его легко поменять. Признаки, что пора:

  • один и тот же набор встретился третий раз;
  • у элемента появились «варианты»: основная / второстепенная кнопка;
  • изменение внешнего вида требует правки в нескольких файлах;
  • строка классов перестала помещаться в голову — больше 15–20 классов.

Извлечение компонента похоже на печать штампа. Пока подпись нужна дважды в год, проще расписаться от руки. Когда её ставят на каждый документ, вырезают штамп. Но штамп на редкий случай — это лишняя работа и лишний предмет в ящике.

Почему не @apply

Соблазнительное решение: собрать классы в CSS-класс и использовать его.

/* ❌ так делать не стоит по умолчанию */
@layer components {
  .btn-primary {
    @apply rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700;
  }
}

Что вы теряете:

  • Возвращается проблема именования — та самая, от которой ушли: .btn-primary, .btn-primary-sm, .btn-primary-sm-icon…
  • Возвращается «мёртвый CSS»: класс остаётся в файле, даже когда его перестали использовать.
  • Ломается переопределение: добавить снаружи px-6 к .btn-primary — снова борьба с каскадом.
  • Не видно, что делает класс, без перехода в CSS-файл.
Когда @apply уместен: вы оформляете чужую разметку, которую не можете изменить (виджет платёжной системы, вывод редактора, письмо), или пишете один-два базовых стиля в @layer base. Это исключение, а не рабочий приём.

Компонент — правильная единица переиспользования

// src/components/ui/badge.tsx
export function Badge({ children }: { children: React.ReactNode }) {
  return (
    <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">
      {children}
    </span>
  )
}

Разметка страницы становится короткой и осмысленной, а всё оформление лежит в одном файле — том самом, куда пойдёт дизайнер с правками.

clsx: классы по условию

import clsx from "clsx"

<div className={clsx(
  "rounded-md px-3 py-2 text-sm",
  isActive && "bg-slate-900 text-white",
  isDisabled && "opacity-50 pointer-events-none",
)} />

Библиотека просто склеивает строки, отбрасывая ложные значения. Ничего волшебного — зато читается лучше, чем конкатенация с тернарными операторами.

tailwind-merge: разрешение конфликтов

Проблема: компонент задаёт px-4, а снаружи передали px-6. Обе утилиты попадут в атрибут, и победит та, что стоит позже в CSS-файле, — то есть результат непредсказуем.

import { twMerge } from "tailwind-merge"

twMerge("px-4 py-2 bg-slate-900", "px-6 bg-sky-600")
// → "py-2 px-6 bg-sky-600"  — конфликтующие классы убраны, побеждает последний

Библиотека знает группы утилит и оставляет из каждой только последнюю. Вместе с clsx это даёт функцию cn(), которая встречается практически в каждом проекте на Tailwind:

// src/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

cva: варианты и размеры

Когда у компонента появляются «виды», условные строки быстро превращаются в лапшу. class-variance-authority описывает их таблицей:

// src/components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
    "focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-slate-900 text-white hover:bg-slate-700",
        outline: "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50",
        ghost: "text-slate-700 hover:bg-slate-100",
        destructive: "bg-rose-600 text-white hover:bg-rose-700",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-9 px-4",
        lg: "h-11 px-6 text-base",
      },
    },
    defaultVariants: { variant: "default", size: "md" },
  }
)

type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}

Использование становится декларативным, а TypeScript подсказывает допустимые значения:

<Button>Сохранить</Button>
<Button variant="outline" size="sm">Отмена</Button>
<Button variant="destructive">Удалить</Button>
<Button className="w-full">Растянутая</Button>
<div class="flex flex-wrap items-center gap-3">
  <button class="inline-flex h-9 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white transition-colors hover:bg-slate-700">Основная</button>
  <button class="inline-flex h-9 items-center justify-center rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50">Контурная</button>
  <button class="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100">Призрачная</button>
  <button class="inline-flex h-9 items-center justify-center rounded-md bg-rose-600 px-4 text-sm font-medium text-white transition-colors hover:bg-rose-700">Опасная</button>
  <button class="inline-flex h-8 items-center justify-center rounded-md bg-slate-900 px-3 text-xs font-medium text-white">Маленькая</button>
</div>

Именно эта связка — cva + cn — используется в компонентах shadcn/ui. Разберём настоящий исходник в уроке 18.

Своя утилита: @utility

Иногда нужен не компонент, а именно утилита — маленькое переиспользуемое свойство, которое должно работать с вариантами (hover:, md:). Для этого есть директива:

/* src/index.css */
@import "tailwindcss";

/* своя утилита: работает с вариантами, как встроенные */
@utility scrollbar-thin {
  scrollbar-width: thin;
  scrollbar-color: var(--color-slate-400) transparent;
}
<div class="scrollbar-thin overflow-y-auto md:scrollbar-thin">…</div>

Отличие от @apply-класса принципиальное: @utility создаёт настоящую утилиту, к которой применимы все модификаторы, и она участвует в разрешении конфликтов наравне со встроенными.

Порядок классов

Технически порядок в атрибуте не влияет ни на что. Практически — влияет на читаемость и на диффы. Поэтому в проектах ставят prettier-plugin-tailwindcss: он сортирует классы в одном каноническом порядке автоматически.

npm install -D prettier prettier-plugin-tailwindcss
// .prettierrc
{
  "plugins": ["prettier-plugin-tailwindcss"]
}
Побочная польза: одинаковый порядок мгновенно показывает дубликаты. Если в строке дважды встречается группа отступов, после сортировки они окажутся рядом.

Где что должно лежать

ЧтоГде
Токены темы@theme в главном CSS
Базовые стили тегов@layer base
Свои утилиты@utility
Компоненты интерфейсаsrc/components/ui/*
Функция cn()src/lib/utils.ts
Варианты компонентарядом с компонентом, через cva