Урок 15. Примеры: от разметки к компоненту
⚡ Кратко
Путь от повторяющейся разметки к компоненту с вариантами: бейдж, кнопка, поле ввода и карточка.
1. Было: три одинаковых бейджа
<span class="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Готово</span>
<span class="rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">Ожидает</span>
<span class="rounded-full bg-rose-100 px-2.5 py-0.5 text-xs font-medium text-rose-800">Ошибка</span>
2. Стало: компонент с вариантами
// src/components/ui/badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
{
variants: {
tone: {
neutral: "bg-slate-100 text-slate-700",
success: "bg-emerald-100 text-emerald-800",
warning: "bg-amber-100 text-amber-800",
danger: "bg-rose-100 text-rose-800",
},
},
defaultVariants: { tone: "neutral" },
}
)
type BadgeProps = React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>
export function Badge({ className, tone, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />
}
<Badge tone="success">Готово</Badge>
<Badge tone="warning">Ожидает</Badge>
<Badge tone="danger">Ошибка</Badge>
<Badge>Черновик</Badge>
Готово
Ожидает
Ошибка
Черновик
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Готово</span>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">Ожидает</span>
<span class="inline-flex items-center rounded-full bg-rose-100 px-2.5 py-0.5 text-xs font-medium text-rose-800">Ошибка</span>
<span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">Черновик</span>
</div>
3. Кнопка со всеми вариантами и размерами
// 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} />
}
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<button class="inline-flex h-8 items-center justify-center rounded-md bg-slate-900 px-3 text-xs font-medium text-white">sm</button>
<button class="inline-flex h-9 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white">md</button>
<button class="inline-flex h-11 items-center justify-center rounded-md bg-slate-900 px-6 text-base font-medium text-white">lg</button>
</div>
<div class="flex flex-wrap items-center gap-3">
<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">outline</button>
<button class="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium text-slate-700 hover:bg-slate-100">ghost</button>
<button class="inline-flex h-9 items-center justify-center rounded-md bg-rose-600 px-4 text-sm font-medium text-white">destructive</button>
<button class="inline-flex h-9 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white opacity-50" disabled>disabled</button>
</div>
</div>
4. Почему нужен tailwind-merge
// без merge: в атрибуте окажутся оба класса
<button className={`px-4 py-2 bg-slate-900 ${className}`} />
// <Button className="px-8" /> → "px-4 py-2 bg-slate-900 px-8" — результат зависит от порядка в CSS
// с merge: остаётся только переданный снаружи
<button className={cn("px-4 py-2 bg-slate-900", className)} />
// → "py-2 bg-slate-900 px-8"
Это не косметика. Без
cn() «переопределить
отступ снаружи» работает случайным образом: иногда получается, иногда нет —
в зависимости от того, какая утилита оказалась ниже в сгенерированном CSS.5. Поле ввода с состоянием ошибки
// src/components/ui/input.tsx
import { cn } from "@/lib/utils"
type InputProps = React.ComponentProps<"input"> & { invalid?: boolean }
export function Input({ className, invalid, ...props }: InputProps) {
return (
<input
aria-invalid={invalid || undefined}
className={cn(
"w-full rounded-md border border-slate-300 px-3 py-2 text-sm",
"placeholder:text-slate-400 focus:ring-2 focus:ring-sky-500/30 focus:outline-none",
"aria-invalid:border-rose-500 aria-invalid:bg-rose-50",
className
)}
{...props}
/>
)
}
<div class="max-w-sm space-y-2">
<input class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm placeholder:text-slate-400 focus:ring-2 focus:ring-sky-500/30 focus:outline-none" placeholder="Обычное состояние">
<input aria-invalid="true" value="Неверное значение" class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm aria-invalid:border-rose-500 aria-invalid:bg-rose-50">
</div>
Состояние ошибки описано вариантом aria-invalid:, а не отдельным
набором классов: атрибут одновременно и оформляет, и сообщает о проблеме
вспомогательным технологиям.
6. Своя утилита вместо @apply
/* src/index.css */
@import "tailwindcss";
/* своя утилита: работает с вариантами, как встроенные */
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--color-slate-400) transparent;
}
<!-- работает с любыми вариантами, как встроенная утилита -->
<div class="scrollbar-thin max-h-64 overflow-y-auto hover:scrollbar-thin">…</div>