Урок 18. Примеры: расширяем компонент

📁 Раздел: shadcn/ui ⏱️ Время изучения: ~55 мин 🎯 Сложность: Продвинутая

⚡ Кратко

Расширяем компонент: свой вариант, свой размер, кнопка-ссылка, кнопка с иконкой и состояние загрузки.

1. Добавляем фирменный вариант

// src/components/ui/button.tsx
const buttonVariants = cva("…базовые классы…", {
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground hover:bg-primary/80",
      // ← новый вариант: работает наравне со встроенными
      brand: "bg-brand text-brand-foreground hover:opacity-90",
    },
  },
})
<Button variant="brand">Оформить подписку</Button>
<button class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-brand px-2.5 text-sm font-medium whitespace-nowrap text-brand-foreground transition-all hover:opacity-90">
  Оформить подписку
</button>

TypeScript немедленно подхватит новое значение: в подсказках variant появится "brand".

2. Добавляем размер

size: {
  default: "h-8 gap-1.5 px-2.5",
  lg: "h-9 gap-1.5 px-2.5",
  xl: "h-11 gap-2 px-5 text-base [&_svg:not([class*='size-'])]:size-5",
}
<div class="flex flex-wrap items-center gap-3">
  <button class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground">default</button>
  <button class="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground">lg</button>
  <button class="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-base font-medium text-primary-foreground">xl</button>
</div>

Заметьте: вместе с высотой меняется и размер иконки внутри — тот самый приём с [&_svg:not([class*='size-'])].

3. Кнопка-ссылка

<Button render={<a href="/reports" />}>Открыть отчёты</Button>
<a href="#" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium whitespace-nowrap text-primary-foreground transition-all hover:bg-primary/80">
  Открыть отчёты
</a>

В разметке это настоящий <a>: работает средний клик, «открыть в новой вкладке» и переход по Enter. Кнопка, стилизованная под ссылку, всего этого не умеет.

4. Иконка и подпись

import { PlusIcon, DownloadIcon } from "lucide-react"

<Button><PlusIcon /> Добавить</Button>
<Button variant="outline">Скачать <DownloadIcon data-icon="inline-end" /></Button>
<Button size="icon" aria-label="Настройки"><SettingsIcon /></Button>
<div class="flex flex-wrap items-center gap-3">
  <button class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground">
    <svg class="size-4 shrink-0" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
    Добавить
  </button>
  <button class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-sm font-medium hover:bg-muted">
    Скачать
    <svg class="size-4 shrink-0" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M10 4v9m0 0l-3-3m3 3l3-3M4 16h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
  </button>
  <button class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-background hover:bg-muted" aria-label="Настройки">
    <svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><circle cx="10" cy="10" r="3"/><path d="M10 2v2m0 12v2M2 10h2m12 0h2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
  </button>
</div>

У кнопки-иконки обязателен aria-label: без текста скринридер не сможет её назвать.

5. Состояние загрузки

function SaveButton({ isLoading }: { isLoading: boolean }) {
  return (
    <Button disabled={isLoading}>
      {isLoading && <LoaderIcon className="animate-spin" />}
      {isLoading ? "Сохраняем…" : "Сохранить"}
    </Button>
  )
}
<div class="flex flex-wrap items-center gap-3">
  <button class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground">Сохранить</button>
  <button class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-primary px-2.5 text-sm font-medium text-primary-foreground opacity-50" disabled>
    <span class="size-4 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground"></span>
    Сохраняем…
  </button>
</div>

Класс disabled:opacity-50 уже есть в базовых классах — компоненту достаточно передать проп disabled.

6. Использование buttonVariants без компонента

Файл экспортирует не только Button, но и buttonVariants. Это позволяет оформить как кнопку что угодно:

import { buttonVariants } from "@/components/ui/button"

<Link to="/settings" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
  Настройки
</Link>

Приём полезен для компонентов маршрутизации, которые нельзя обернуть через render.