DevToolBox무료
블로그

Tailwind CSS 컴포넌트 패턴: 2026년 유틸리티 클래스로 재사용 가능한 UI 구축

14분by DevToolBox

Tailwind CSS의 유틸리티 우선 접근법은 제대로 구성하지 않으면 부풀어 오른 JSX가 될 수 있습니다.

class-variance-authority (cva): 타입 안전 변형

cva는 Tailwind에서 컴포넌트 변형을 관리하는 표준 방법입니다.

// Using class-variance-authority (cva) for type-safe variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
    // Base classes applied to every variant
    'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
    {
        variants: {
            variant: {
                default: 'bg-primary text-primary-foreground hover:bg-primary/90',
                destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
                outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
                ghost: 'hover:bg-accent hover:text-accent-foreground',
                link: 'text-primary underline-offset-4 hover:underline',
            },
            size: {
                default: 'h-10 px-4 py-2',
                sm: 'h-9 rounded-md px-3',
                lg: 'h-11 rounded-md px-8',
                icon: 'h-10 w-10',
            },
        },
        defaultVariants: {
            variant: 'default',
            size: 'default',
        },
    }
);

interface ButtonProps
    extends React.ButtonHTMLAttributes<HTMLButtonElement>,
        VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
    return (
        <button
            className={cn(buttonVariants({ variant, size, className }))}
            {...props}
        />
    );
}

// Usage:
// <Button variant="outline" size="sm">Click me</Button>
// <Button variant="destructive">Delete</Button>

@apply를 언제 사용할까

@apply는 반복되는 Tailwind 클래스 조합을 CSS 클래스로 추출합니다.

/* globals.css — using @apply to extract reusable patterns */
@layer components {
    /* Card component pattern */
    .card {
        @apply rounded-lg border bg-card text-card-foreground shadow-sm;
    }

    .card-header {
        @apply flex flex-col space-y-1.5 p-6;
    }

    .card-title {
        @apply text-2xl font-semibold leading-none tracking-tight;
    }

    .card-content {
        @apply p-6 pt-0;
    }

    /* Form field pattern */
    .form-label {
        @apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
    }

    .form-input {
        @apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
               ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
               placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2
               focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
    }

    /* Badge pattern */
    .badge {
        @apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
               transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
    }
}

/* Use sparingly — prefer utility classes in JSX for maintainability */

cn() 유틸리티: 안전하게 클래스 병합

clsx는 조건부 클래스를 처리하고, tailwind-merge는 Tailwind 충돌을 해결합니다.

// lib/utils.ts — the cn() helper (used everywhere in shadcn/ui)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

// clsx: conditionally join class names
// twMerge: intelligently merge Tailwind classes (handles conflicts)
export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs));
}

// Examples:
cn('px-4 py-2', 'px-6')           // 'py-2 px-6' (px-4 overridden)
cn('text-red-500', isActive && 'text-blue-500')  // conditional
cn({ 'opacity-50': disabled, 'cursor-not-allowed': disabled })

Headless UI: 접근 가능한 인터랙티브 컴포넌트

Headless UI는 스타일 없이 완전히 접근 가능한 인터랙티브 컴포넌트를 제공합니다.

// Using Headless UI (Tailwind Labs) for accessible components
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment, useState } from 'react';

const people = [
    { id: 1, name: 'Durward Reynolds' },
    { id: 2, name: 'Kenton Towne' },
    { id: 3, name: 'Therese Wunsch' },
];

export function Select() {
    const [selected, setSelected] = useState(people[0]);

    return (
        <Listbox value={selected} onChange={setSelected}>
            <Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
                <span className="block truncate">{selected.name}</span>
                <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
                    <ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
                </span>
            </Listbox.Button>
            <Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
                <Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
                    {people.map((person) => (
                        <Listbox.Option key={person.id} value={person} className={({ active }) => cn('relative cursor-default select-none py-2 pl-10 pr-4', active ? 'bg-amber-100 text-amber-900' : 'text-gray-900')}>
                            {({ selected }) => (
                                <>
                                    <span className={cn('block truncate', selected ? 'font-medium' : 'font-normal')}>{person.name}</span>
                                    {selected ? <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600"><CheckIcon className="h-5 w-5" /></span> : null}
                                </>
                            )}
                        </Listbox.Option>
                    ))}
                </Listbox.Options>
            </Transition>
        </Listbox>
    );
}

Tailwind 설정: 디자인 토큰과 테마

CSS 변수로 Tailwind를 확장하면 JavaScript 없이 테마 전환이 가능합니다.

// tailwind.config.ts — extend with design tokens
import type { Config } from 'tailwindcss';

const config: Config = {
    darkMode: ['class'],
    content: ['./src/**/*.{ts,tsx}'],
    theme: {
        extend: {
            colors: {
                // CSS variable-based colors for theming
                border: 'hsl(var(--border))',
                input: 'hsl(var(--input))',
                ring: 'hsl(var(--ring))',
                background: 'hsl(var(--background))',
                foreground: 'hsl(var(--foreground))',
                primary: {
                    DEFAULT: 'hsl(var(--primary))',
                    foreground: 'hsl(var(--primary-foreground))',
                },
                destructive: {
                    DEFAULT: 'hsl(var(--destructive))',
                    foreground: 'hsl(var(--destructive-foreground))',
                },
            },
            borderRadius: {
                lg: 'var(--radius)',
                md: 'calc(var(--radius) - 2px)',
                sm: 'calc(var(--radius) - 4px)',
            },
            keyframes: {
                'accordion-down': {
                    from: { height: '0' },
                    to: { height: 'var(--radix-accordion-content-height)' },
                },
            },
            animation: {
                'accordion-down': 'accordion-down 0.2s ease-out',
            },
        },
    },
    plugins: [require('tailwindcss-animate')],
};

export default config;

컴포넌트 라이브러리 접근 방식

LibraryApproachCode OwnershipThemingAccessibility
shadcn/uiCopy-paste + Radix + CVAYou own the codeCSS variablesRadix primitives
Chakra UIComponent librarynpm dependencyTheme objectBuilt-in
MUI (Material)Component librarynpm dependencyTheme providerBuilt-in
MantineComponent librarynpm dependencyCSS variablesBuilt-in
DaisyUITailwind pluginConfig onlyCSS variablesManual

모범 사례

  • 변형이 2개 이상인 모든 컴포넌트에 cva를 사용하세요.
  • 클래스 병합에는 항상 cn()을 사용하세요.
  • 컴포넌트에는 @apply를 사용하지 마세요.
  • CSS 변수를 통한 디자인 토큰으로 다크 모드를 구현하세요.
  • 드롭다운, 모달에는 Headless UI나 Radix UI를 사용하세요.

자주 묻는 질문

Tailwind와 CSS-in-JS 중 어느 것을 선택해야 하나요?

2026년에는 Tailwind + CVA + shadcn/ui가 새 React 프로젝트의 지배적인 패턴입니다.

shadcn/ui란 무엇인가요?

shadcn/ui는 Radix UI + Tailwind + CVA로 만들어진 복사-붙여넣기 컴포넌트 모음입니다.

Tailwind로 다크 모드를 어떻게 처리하나요?

tailwind.config.ts에 darkMode: "class"를 추가하고 dark: 접두사를 사용합니다.

프로덕션에서 Tailwind 클래스 퍼지를 방지하려면?

tailwind.config.ts의 content 배열에 Tailwind 클래스를 사용하는 모든 파일을 포함시키세요.

Tailwind가 디자인 시스템에 적합한가요?

네, 하지만 규율이 필요합니다. CSS 변수를 이용한 디자인 토큰을 사용하세요.

관련 도구

𝕏 Twitterin LinkedIn
도움이 되었나요?

최신 소식 받기

주간 개발 팁과 새 도구 알림을 받으세요.

스팸 없음. 언제든 구독 해지 가능.

Try These Related Tools

🌈CSS Gradient GeneratorCSS Triangle Generator🎨Color Picker Online

Related Articles

CSS Grid 마스터: 2026년 실제 예제 완전 가이드

CSS Grid 레이아웃 마스터 2026: grid-template areas, 자동 배치, subgrid 및 반응형 레이아웃.

React Query 패턴 2026: TanStack Query로 데이터 페칭, 캐싱, 뮤테이션

React Query (TanStack Query) 패턴 마스터 2026: useQuery, useMutation, 낙관적 업데이트, 서버 상태 관리.