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 businessinput.tsxcard.tsxbadge.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;classNamemereu ultimul.- Extinzi props-urile native, variante finite cu
cva, accesibilitate în bază; pentru componente complexe — headless + Tailwind.