UI kit: Button, Input, cn()
The shared/ui structure, cn with clsx + tailwind-merge, variants with cva, native props.
Updated
What a UI kit is
A UI kit is the project's set of base components — Button, Input, Card, Badge, Dialog — built once, following the design, and used everywhere. Every new page is composed from them, instead of being styled from scratch.
What you gain: visual consistency, accessibility solved once, design changes from a single place.
Where they live — the folder structure
src/shared/ui/button.tsxgeneric components, no business logicinput.tsxcard.tsxbadge.tsx
lib/cn.tsa helper for combining classes
entities/product/ui/product-card.tsxuses Card + Badge from shared/ui
app/globals.csstokens: colors, fonts, the text scale, @utility
The FSD rule: shared/ui knows nothing about products or users — it receives only generic props. Components with a business meaning (ProductCard) live in entities or features and compose the pieces from shared/ui.
cn() — the central piece
Every component in the kit receives a className from outside, which has to be combined with its own classes. For that you use a cn helper:
// shared/lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))| Piece | What it solves | Example |
|---|---|---|
clsx |
conditional classes, ignores false / null |
clsx('btn', active && 'active') |
tailwind-merge |
conflicts: the last class in the same group wins | twMerge('px-2 py-1', 'px-4') → 'py-1 px-4' |
Without tailwind-merge, <Button className="px-6"> would have both px-4 (from the component) and px-6 — and the winner would be decided by the order in the generated CSS, not by the order you wrote.
(In this app cn is the simple version, without tailwind-merge, because we don't override classes from outside.)
Button — the anatomy of a kit component
// 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} />
}What each decision does:
| Decision | Why |
|---|---|
ComponentProps<'button'> |
you get disabled, onClick, aria-*, ref (React 19) for free — it's a real <button> |
...props at the end |
every native attribute is passed through |
type = 'button' by default |
doesn't submit the form by accident |
className last in cn |
whoever uses the component can adjust it (mt-4, w-full) |
finite variants (primary, ghost) |
you don't accept color="#f00" — the design stays consistent |
focus-visible, disabled in the base |
accessibility solved once |
Input and Card — the same 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 — the error state comes from an ARIA attribute, so it's both accessible and styled.
Complex components: headless + Tailwind
For Dialog, Dropdown, Tabs, Tooltip, Combobox — behavior that's hard to get right (focus trapping, keyboard, ARIA) — you use a headless library (logic only, zero style) and add the classes yourself:
| Library | What it offers |
|---|---|
| Radix UI / Base UI | accessible, unstyled primitives |
| React Aria (Adobe) | hooks and components, very rigorous accessibility |
| shadcn/ui | not a library — it copies Radix + Tailwind + cva + cn components into your project, exactly the pattern above |
Summary
- A UI kit = generic components in
shared/ui; business components compose them. cn = twMerge(clsx(...)): conditionals + resolved conflicts;classNamealways last.- Extend native props, finite variants with
cva, accessibility in the base; for complex components — headless + Tailwind.