Когда извлекать
Не спешите. Пока набор классов встретился один-два раза, дублирование дешевле абстракции: его видно, его легко поменять. Признаки, что пора:
- один и тот же набор встретился третий раз;
- у элемента появились «варианты»: основная / второстепенная кнопка;
- изменение внешнего вида требует правки в нескольких файлах;
- строка классов перестала помещаться в голову — больше 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 |