webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Tooling
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Forms
  12. 12Data and backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animations
  16. 16Testing
  17. 17Architecture
Tailwind CSS · Lesson 6 of 6

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 logic
        • input.tsx
        • card.tsx
        • badge.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; className always last.
  • Extend native props, finite variants with cva, accessibility in the base; for complex components — headless + Tailwind.

Official sources

Exercises

Was this page helpful?

One tap — no account needed.