webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Unelte
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Formulare
  12. 12Date și backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animații
  16. 16Testare
  17. 17Arhitectură
Tailwind CSS · Lecția 6 din 6

UI kit: Button, Input, cn()

Structura shared/ui, cn cu clsx + tailwind-merge, variante cu cva, props native.

Actualizat

Ce este un UI kit

Un UI kit e setul de componente de bază ale proiectului — Button, Input, Card, Badge, Dialog — construite o singură dată, după design, și folosite peste tot. Orice pagină nouă se compune din ele, nu se mai stilizează de la zero.

Ce câștigi: consistență vizuală, accesibilitate rezolvată o dată, schimbări de design dintr-un singur loc.

Unde stau — structura de foldere

  • src/
    • shared/
      • ui/
        • button.tsxcomponente generice, fără logică de business
        • input.tsx
        • card.tsx
        • badge.tsx
      • lib/
        • cn.tshelper pentru combinarea claselor
    • entities/
      • product/
        • ui/
          • product-card.tsxfolosește Card + Badge din shared/ui
  • app/
    • globals.csstokens: culori, fonturi, scala de text, @utility

Regula FSD: shared/ui nu știe nimic despre produse sau useri — primește doar props generice. Componentele cu sens de business (ProductCard) stau în entities sau features și compun piesele din shared/ui.

cn() — piesa centrală

Orice componentă din kit primește un className din afară, care trebuie combinat cu clasele ei. Pentru asta folosești un helper cn:

// shared/lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
Piesă Ce rezolvă Exemplu
clsx clase condiționate, ignoră false / null clsx('btn', active && 'active')
tailwind-merge conflictele: ultima clasă din același grup câștigă twMerge('px-2 py-1', 'px-4') → 'py-1 px-4'

Fără tailwind-merge, <Button className="px-6"> ar avea și px-4 (din componentă), și px-6 — iar câștigătoarea ar fi decisă de ordinea din CSS-ul generat, nu de ordinea scrisă de tine.

(În aplicația asta cn e varianta simplă, fără tailwind-merge, pentru că nu suprascriem clase din afară.)

Button — anatomia unei componente de kit

// 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} />
}

Ce face fiecare decizie:

Decizie De ce
ComponentProps<'button'> primești gratis disabled, onClick, aria-*, ref (React 19) — e un <button> adevărat
...props la final orice atribut nativ trece mai departe
type = 'button' implicit nu trimite formularul din greșeală
className ultimul în cn cine folosește componenta poate ajusta (mt-4, w-full)
variante finite (primary, ghost) nu primești color="#f00" — designul rămâne consistent
focus-visible, disabled în bază accesibilitate rezolvată o dată

Input și Card — același pattern

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 — starea de eroare vine dintr-un atribut ARIA, deci e și accesibilă, și stilizată.

Componente complexe: headless + Tailwind

Pentru Dialog, Dropdown, Tabs, Tooltip, Combobox — comportament greu de făcut corect (focus trap, tastatură, ARIA) — folosești o librărie headless (doar logică, zero stil) și pui tu clasele:

Librărie Ce oferă
Radix UI / Base UI primitive accesibile, nestilizate
React Aria (Adobe) hook-uri și componente, accesibilitate foarte riguroasă
shadcn/ui nu e o librărie — copiază în proiectul tău componente Radix + Tailwind + cva + cn, exact pattern-ul de mai sus

Pe scurt

  • UI kit = componente generice în shared/ui; componentele de business le compun.
  • cn = twMerge(clsx(...)): condiționale + conflicte rezolvate; className mereu ultimul.
  • Extinzi props-urile native, variante finite cu cva, accesibilitate în bază; pentru componente complexe — headless + Tailwind.

Surse oficiale

Exerciții

Ți-a fost utilă pagina?

Un click — fără cont.