Files
claude-instructions/rules/markup/tailwindcss-strategy.md

26 KiB

paths
paths
app/**/*.vue
app/assets/**/*.css

TailwindCSS v4 스타일링 전략 Rules

Nuxt 4 + Vue 3 + TailwindCSS v4 + shadcn-vue 환경 기준 최종 업데이트: 2026-04-07


빠른 참조 체크리스트

스타일링 작업 전 아래 항목을 확인한다.

  • 클래스 순서가 레이아웃 -> 박스 -> 타이포 -> 시각 -> 인터랙티브 순인가?
  • 기본 스타일이 모바일 기준이고, sm: / md: / lg: 순서로 확장하는가?
  • 동적 클래스를 문자열 보간(`bg-${color}-500`)으로 생성하지 않았는가?
  • 색상/간격에 하드코딩 값(bg-[#1a1a2e], mt-[17px]) 대신 CSS 변수 또는 디자인 토큰을 사용했는가?
  • 다크모드 대응 시 CSS 변수(bg-background, text-foreground)를 우선 사용했는가?
  • @apply가 3개 이상 컴포넌트에서 반복되는 복합 패턴에만 사용되었는가?
  • 변형(variant) 처리는 cva + cn() 패턴을 사용하는가?
  • shadcn-vue 컴포넌트를 직접 수정하지 않고 래퍼 컴포넌트로 확장했는가?
  • prettier-plugin-tailwindcss가 설정되어 자동 정렬이 적용되는가?
  • !important(! prefix)를 사용하지 않았는가?

규칙 상세

Rule 1: 클래스 순서 (레이아웃 -> 박스 -> 타이포 -> 시각 -> 인터랙티브)

클래스는 아래 순서대로 작성한다. prettier-plugin-tailwindcss로 자동 정렬을 강제한다.

1. 레이아웃           -> display, position, z-index, overflow
2. 박스 모델          -> width, height, margin, padding, border, rounded
3. 플렉스/그리드       -> flex-*, grid-*, gap, justify-*, items-*
4. 타이포그래피        -> font-*, text-*, leading-*, tracking-*
5. 시각 효과          -> bg-*, shadow-*, opacity-, ring-*
6. 트랜지션/애니메이션  -> transition-*, duration-*, animate-*
7. 인터랙티브         -> cursor-*, select-*, pointer-events-*
8. 상태 변형자        -> hover:, focus:, active:, disabled:
9. 반응형 변형자       -> sm:, md:, lg:, xl:, 2xl:
10. 다크모드          -> dark:

DO

<template>
  <!-- 레이아웃 -> 박스 -> 플렉스 -> 타이포 -> 시각 -> 인터랙티브 순서 -->
  <button
    class="
      inline-flex
      h-10 rounded-md px-4 py-2
      items-center justify-center gap-2
      text-sm font-medium
      bg-primary text-primary-foreground shadow-sm
      transition-colors duration-200
      cursor-pointer
      hover:bg-primary/90
      focus-visible:ring-2 focus-visible:ring-ring
      disabled:cursor-not-allowed disabled:opacity-50
    "
  >
    저장
  </button>
</template>

DON'T

<template>
  <!-- 순서 뒤섞임: 가독성 저하    일관성 훼손 -->
  <button
    class="
      hover:bg-primary/90 bg-primary text-sm cursor-pointer
      px-4 py-2 inline-flex font-medium shadow-sm
      h-10 rounded-md items-center disabled:opacity-50
    "
  >
    저장
  </button>
</template>

Prettier 설정

// .prettierrc
{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindConfig": "./tailwind.config.ts",
  "tailwindFunctions": ["cn", "cva", "clsx", "twMerge"]
}

tailwindFunctionscn, cva 등을 등록해야 유틸 함수 내부의 클래스도 자동 정렬된다.


Rule 2: Mobile-first 반응형 (기본=모바일, sm/md/lg/xl 순서)

기본 스타일은 모바일 기준으로 작성하고, 더 큰 화면에서 순서대로 덮어쓴다.

기본(base)  -> 0px~     : 모바일 (prefix 없음)
sm          -> 640px~   : 대형 모바일, 소형 태블릿
md          -> 768px~   : 태블릿
lg          -> 1024px~  : 소형 데스크탑
xl          -> 1280px~  : 데스크탑
2xl         -> 1536px~  : 대형 모니터

DO

<template>
  <!-- Mobile-first: 기본 1 -> sm 2 -> lg 3 -> xl 4 -->
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
    <ProductCard v-for="item in products" :key="item.id" :product="item" />
  </div>

  <!-- 반응형 숨김/표시 -->
  <MobileNav class="block sm:hidden" />
  <DesktopNav class="hidden sm:flex" />

  <!-- 반응형 컨테이너 -->
  <div class="mx-auto w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
    <slot />
  </div>
</template>

DON'T

<template>
  <!-- Desktop-first: max-* 남용으로 역방향 덮어쓰기 발생 -->
  <div class="grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1">
    <!-- 가독성 저하, 모바일 기준 불명확 -->
  </div>

  <!-- 변경되지 않는 값에 불필요한 반응형 prefix 반복 -->
  <p class="text-xs sm:text-xs md:text-sm lg:text-sm">...</p>
  <!-- text-xs sm:text-sm 으로 충분 -->
</template>

반응형 타이포그래피 패턴

<template>
  <!-- 모바일에서 작게, 태블릿부터 크게 -->
  <h1 class="text-2xl font-bold sm:text-3xl lg:text-4xl">페이지 제목</h1>

  <!-- 모바일에서 세로 정렬, 데스크탑에서 가로 정렬 -->
  <div class="flex flex-col gap-4 md:flex-row md:items-center md:gap-8">
    <div>왼쪽 콘텐츠</div>
    <div>오른쪽 콘텐츠</div>
  </div>
</template>

Rule 3: 컴포넌트 스타일링 (cva+cn() 패턴, @apply 기준, shadcn-vue 확장)

cn() 유틸리티

clsx + tailwind-merge를 조합한 cn() 함수를 사용한다. 조건부 클래스 병합 및 Tailwind 클래스 충돌 해결을 자동으로 처리한다.

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

export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs))
}

DO

<script setup lang="ts">
import { cn } from '@/lib/utils'

interface Props {
  class?: string
  active?: boolean
}

const props = defineProps<Props>()
</script>

<template>
  <!-- cn()으로 외부 클래스와 내부 클래스를 안전하게 병합 -->
  <div :class="cn(
    'rounded-lg border p-4 transition-colors',
    props.active && 'border-primary bg-primary/5',
    props.class
  )">
    <slot />
  </div>
</template>

DON'T

<template>
  <!-- 클래스 충돌  예측 불가 (p-4 p-2 동시 적용) -->
  <div :class="`rounded-lg border p-4 ${props.class}`">
    <slot />
  </div>
</template>

cva (class-variance-authority) 패턴

변형(variant)이 있는 컴포넌트는 반드시 cva로 정의한다.

DO

// app/components/ui/badge/index.ts
import { cva, type VariantProps } from 'class-variance-authority'

export const badgeVariants = cva(
  // 기본 클래스
  'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground',
        secondary: 'bg-secondary text-secondary-foreground',
        outline: 'border border-input text-foreground',
        destructive: 'bg-destructive text-destructive-foreground',
      },
    },
    defaultVariants: { variant: 'default' },
  }
)

export type BadgeVariants = VariantProps<typeof badgeVariants>
<!-- app/components/ui/badge/Badge.vue -->
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { badgeVariants, type BadgeVariants } from '.'

interface Props {
  variant?: BadgeVariants['variant']
  class?: string
}

const props = withDefaults(defineProps<Props>(), { variant: 'default' })
</script>

<template>
  <span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
    <slot />
  </span>
</template>

DON'T

<script setup lang="ts">
// v-if 분기로 변형 처리 -> 유지보수 어려움, 누락 위험
const props = defineProps<{ variant: string }>()
</script>

<template>
  <span
    :class="{
      'bg-primary text-white': props.variant === 'default',
      'bg-gray-200 text-gray-800': props.variant === 'secondary',
      'border text-gray-800': props.variant === 'outline',
    }"
  >
    <slot />
  </span>
</template>

@apply 사용 기준

@apply3개 이상의 컴포넌트에서 반복되는 복합 패턴에만 허용한다.

DO

/* 허용: 여러 컴포넌트에서 반복 사용되는 복합 패턴 */
.btn-base {
  @apply inline-flex items-center justify-center rounded-md text-sm font-medium
         transition-colors focus-visible:outline-none focus-visible:ring-2
         focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50;
}

DON'T

/* 금지: 단일 클래스 추상화 -> 의미 없는 간접참조 */
.text-blue { @apply text-blue-500; }
.mt-small { @apply mt-2; }
.flex-center { @apply flex items-center justify-center; }

shadcn-vue 커스터마이징

shadcn-vue 컴포넌트 원본 파일을 직접 수정하지 않는다. 래퍼 컴포넌트로 확장한다.

DO

<!-- app/components/AppButton.vue -->
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

interface Props {
  variant?: 'default' | 'destructive' | 'outline' | 'ghost'
  size?: 'default' | 'sm' | 'lg' | 'icon'
  loading?: boolean
  class?: string
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'default',
  size: 'default',
  loading: false,
})
</script>

<template>
  <Button
    :variant="props.variant"
    :size="props.size"
    :disabled="props.loading"
    :class="cn('gap-2', props.class)"
  >
    <span
      v-if="props.loading"
      class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
    />
    <slot />
  </Button>
</template>

DON'T

<!-- 금지: shadcn-vue 원본 파일(components/ui/button/Button.vue) 직접 수정 -->
<!-- 업데이트  충돌 발생, 변경 추적 불가 -->

Rule 4: 다크모드 (CSS 변수 우선, dark: prefix 보완용)

CSS 변수 기반 테마를 우선 사용한다. dark: prefix는 CSS 변수로 처리할 수 없는 경우에만 보완 용도로 사용한다.

CSS 변수 정의

/* app/assets/css/main.css */
@import "tailwindcss";

:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 221.2 83.2% 53.3%;
  --primary-foreground: 210 40% 98%;
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --border: 214.3 31.8% 91.4%;
  --radius: 0.5rem;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --primary: 217.2 91.2% 59.8%;
  --border: 217.2 32.6% 17.5%;
}

DO

<template>
  <!-- CSS 변수 사용: 라이트/다크 자동 전환 -->
  <div class="bg-background text-foreground border border-border rounded-lg p-4">
    <h2 class="text-foreground">제목</h2>
    <p class="text-muted-foreground">설명 텍스트</p>
  </div>

  <!-- dark: prefix는 CSS 변수로 불가한 경우에만 보완 -->
  <div class="shadow-sm dark:shadow-md">
    <!-- shadow 값은 CSS 변수로 관리하기 어렵기 때문에 dark: 사용 -->
  </div>
</template>

DON'T

<template>
  <!-- 금지: 모든 속성에 dark: prefix 중복 사용 -->
  <div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-white border-gray-200 dark:border-gray-700 p-4">
    <h2 class="text-gray-900 dark:text-white">제목</h2>
    <p class="text-gray-600 dark:text-gray-400">설명</p>
  </div>
  <!-- CSS 변수를 쓰면 dark: 없이 해결 -->
</template>

다크모드 토글 구현

<script setup lang="ts">
// useColorMode (Nuxt 내장)으로 다크모드 전환
const colorMode = useColorMode()

function toggleDark() {
  colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>

<template>
  <button type="button" @click="toggleDark" aria-label="테마 전환">
    <SunIcon v-if="colorMode.value === 'dark'" class="h-5 w-5" />
    <MoonIcon v-else class="h-5 w-5" />
  </button>
</template>

Rule 5: 상태 기반 스타일링 (hover/focus/active/disabled, group, peer)

기본 상태 변형자 순서

상태 변형자는 hover -> focus-visible -> active -> disabled 순서로 작성한다.

DO

<template>
  <button
    class="
      rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
      transition-all duration-200
      hover:bg-primary/90 hover:shadow-md
      focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
      active:scale-95
      disabled:cursor-not-allowed disabled:opacity-50
    "
  >
    제출
  </button>
</template>

DON'T

<template>
  <!-- focus 대신 focus-visible 사용: 마우스 클릭  불필요한 포커스  방지 -->
  <button
    class="
      rounded-md bg-primary px-4 py-2
      focus:ring-2 focus:ring-blue-500
      focus:outline-none
    "
  >
    제출
  </button>
  <!-- outline: none 전역 제거는 접근성 위반 -->
</template>

group 패턴 (부모 상태 -> 자식 스타일)

부모 요소에 group 클래스를 부여하고, 자식에서 group-hover:, group-focus: 등으로 반응한다.

DO

<template>
  <div class="group relative rounded-xl border bg-card p-6 transition-all hover:border-primary hover:shadow-lg">
    <h3 class="transition-colors group-hover:text-primary">카드 제목</h3>
    <p class="text-muted-foreground">카드 설명</p>
    <ArrowRight
      class="absolute right-4 top-4 text-muted-foreground transition-transform group-hover:translate-x-1 group-hover:text-primary"
    />
  </div>
</template>

중첩 group이 필요한 경우 group/{name} 문법을 사용한다.

<template>
  <div class="group/card rounded-xl border p-6">
    <div class="group/header flex items-center gap-2">
      <h3 class="group-hover/header:underline">제목</h3>
    </div>
    <p class="group-hover/card:text-foreground">본문</p>
  </div>
</template>

peer 패턴 (형제 상태 -> 형제 스타일)

peer 클래스를 가진 요소의 상태에 따라 뒤에 오는 형제 요소의 스타일을 변경한다.

DO

<template>
  <!-- 체크박스 체크 상태에 따라 라벨 스타일 변경 -->
  <label class="flex cursor-pointer items-center gap-3">
    <input type="checkbox" class="peer h-4 w-4 rounded accent-primary" />
    <span class="text-muted-foreground transition-colors peer-checked:font-medium peer-checked:text-foreground">
      약관에 동의합니다
    </span>
  </label>

  <!-- 입력 필드 유효성에 따른 메시지 표시 -->
  <div>
    <input type="email" class="peer w-full rounded-md border px-3 py-2 peer-invalid:border-red-500" required />
    <p class="mt-1 hidden text-sm text-red-500 peer-invalid:block">
      올바른 이메일을 입력하세요.
    </p>
  </div>
</template>

DON'T

<template>
  <!-- 금지: peer 요소보다 앞에 있는 형제는 peer 상태를 참조할  없다 -->
  <span class="peer-checked:font-medium">라벨</span>
  <input type="checkbox" class="peer" />
  <!-- peer 대상은 반드시 형제보다 먼저 DOM에 위치해야 한다 -->
</template>

Rule 6: TailwindCSS v4 신기능 (@theme, @container 쿼리)

@theme 디렉티브 (CSS-first 설정)

TailwindCSS v4에서는 tailwind.config.ts 대신 CSS 파일 내 @theme 디렉티브로 토큰을 정의한다.

DO

/* app/assets/css/main.css */
@import "tailwindcss";

@theme {
  /* 폰트 */
  --font-sans: "Pretendard", "Noto Sans KR", system-ui, sans-serif;

  /* 커스텀 색상 */
  --color-brand-500: oklch(60% 0.20 250);
  --color-brand-600: oklch(51% 0.19 250);

  /* 커스텀 간격 */
  --spacing-18: 4.5rem;

  /* 커스텀 애니메이션 */
  --animate-fade-in: fade-in 0.3s ease-out;
  --animate-slide-up: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);

  /* 커스텀 그림자 */
  --shadow-soft: 0 2px 8px rgb(0 0 0 / 0.08), 0 1px 3px rgb(0 0 0 / 0.06);
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes slide-up {
  from { transform: translateY(8px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}
<template>
  <!-- @theme에서 정의한 토큰은 일반 Tailwind 클래스처럼 사용 가능 -->
  <div class="font-sans text-brand-500 shadow-soft animate-fade-in p-18">
    커스텀 토큰 사용 예시
  </div>
</template>

DON'T

/* 금지: v4에서 @theme 대신 옛 방식(tailwind.config.ts extend)으로 토큰 정의 */
/* tailwind.config.ts의 theme.extend는 여전히 동작하지만 @theme이 권장 방식 */

@container 쿼리

뷰포트가 아닌 부모 컨테이너 크기 기반으로 반응형 스타일을 적용한다. 사이드바, 카드 리스트 등 배치 위치에 따라 크기가 달라지는 컴포넌트에 유용하다.

DO

<template>
  <!-- 부모에 @container 선언 -->
  <div class="@container">
    <!-- 컨테이너 크기 기반 반응형: @sm, @md, @lg  사용 -->
    <div class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @lg:grid-cols-3">
      <ProductCard v-for="item in items" :key="item.id" :product="item" />
    </div>
  </div>

  <!-- 이름이 있는 컨테이너: 중첩  특정 조상 참조 -->
  <div class="@container/sidebar">
    <nav class="flex flex-col @md/sidebar:flex-row">
      <!-- sidebar 컨테이너 크기 기준으로 레이아웃 변경 -->
    </nav>
  </div>
</template>

DON'T

<template>
  <!-- 금지: 뷰포트 반응형으로 컨테이너 내부 레이아웃 제어 -->
  <!-- 사이드바에 배치하면 의도와 다르게 동작 -->
  <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
    <!-- 뷰포트가 lg여도 사이드바 안에서는 공간이 좁다 -->
  </div>
</template>

Rule 7: 성능 최적화 (동적 클래스 생성 금지, 클래스 매핑 패턴)

TailwindCSS v4는 소스 코드를 정적 분석하여 사용된 클래스만 번들에 포함한다. 런타임에 문자열을 조합하면 빌드 시 해당 클래스가 누락된다.

동적 클래스 생성 금지 (Critical)

DO

<script setup lang="ts">
// 완전한 클래스명을 객체 맵으로 정의
const COLOR_CLASS_MAP: Record<string, string> = {
  blue: 'text-blue-500 bg-blue-100',
  red: 'text-red-500 bg-red-100',
  green: 'text-green-500 bg-green-100',
} as const

const colorClass = computed(() => COLOR_CLASS_MAP[props.color] ?? COLOR_CLASS_MAP.blue)
</script>

<template>
  <span :class="colorClass">{{ props.label }}</span>
</template>

DON'T

<script setup lang="ts">
// 금지: 문자열 보간으로 클래스 동적 생성 -> 빌드 시 누락됨
const badClass = `text-${props.color}-500`
const badBg = `bg-${props.color}-100`
</script>

<template>
  <!-- 금지: 템플릿에서도 동적 문자열 조합 금지 -->
  <span :class="`text-${color}-500 bg-${color}-100`">{{ label }}</span>
</template>

조건부 클래스 패턴

DO

<template>
  <!-- 삼항 연산자: 완전한 클래스명 사용 -->
  <div :class="isActive ? 'bg-primary text-white' : 'bg-muted text-muted-foreground'">
    콘텐츠
  </div>

  <!-- 객체 문법: 완전한 클래스명 사용 -->
  <div :class="{
    'border-primary bg-primary/5': isSelected,
    'border-border bg-background': !isSelected,
    'opacity-50 pointer-events-none': isDisabled,
  }">
    콘텐츠
  </div>
</template>

DON'T

<script setup lang="ts">
// 금지: 배열 인덱스나 변수로 Tailwind 클래스 구성
const sizes = ['text-sm', 'text-base', 'text-lg']
const sizeClass = sizes[props.sizeIndex] // 정적 분석 불가
</script>

Rule 8: 재사용 패턴 (디자인 토큰, 상태 클래스 매핑)

디자인 토큰 (CSS 변수 체계)

프로젝트 전역에서 사용하는 레이아웃 수치, z-index, 트랜지션 등은 CSS 변수로 중앙 관리한다.

/* app/assets/css/tokens.css */
:root {
  /* 레이아웃 */
  --header-height: 4rem;
  --sidebar-width: 16rem;
  --content-max-width: 75rem;

  /* Z-index 계층 */
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-modal: 400;
  --z-toast: 600;
  --z-tooltip: 700;

  /* 트랜지션 */
  --duration-fast: 100ms;
  --duration-base: 200ms;
  --ease-spring: cubic-bezier(0.16, 1, 0.3, 1);
}
<template>
  <!-- 토큰 참조: 임의값(arbitrary value) 문법으로 CSS 변수 활용 -->
  <header class="sticky top-0 z-[var(--z-sticky)] h-[var(--header-height)]">
    <!-- 헤더 내용 -->
  </header>
</template>

상태별 클래스 매핑

반복되는 상태 스타일을 타입 안전한 매핑 객체로 관리한다.

DO

// app/utils/statusStyles.ts
type StatusVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral'

export const STATUS_STYLES: Record<StatusVariant, { badge: string; text: string }> = {
  success: {
    badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
    text: 'text-emerald-700 dark:text-emerald-400',
  },
  warning: {
    badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
    text: 'text-amber-700 dark:text-amber-400',
  },
  error: {
    badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
    text: 'text-red-700 dark:text-red-400',
  },
  info: {
    badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
    text: 'text-blue-700 dark:text-blue-400',
  },
  neutral: {
    badge: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
    text: 'text-gray-700 dark:text-gray-400',
  },
} as const
<script setup lang="ts">
import { STATUS_STYLES } from '@/utils/statusStyles'

interface Props {
  status: keyof typeof STATUS_STYLES
  label: string
}

const props = defineProps<Props>()
</script>

<template>
  <span :class="['inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[props.status].badge]">
    {{ props.label }}
  </span>
</template>

DON'T

<template>
  <!-- 금지: 매번 인라인으로 상태별 스타일 작성 -> 불일치 발생 -->
  <span
    :class="{
      'bg-green-100 text-green-800': status === 'success',
      'bg-yellow-100 text-yellow-800': status === 'warning',
      'bg-red-100 text-red-800': status === 'error',
    }"
  >
    {{ label }}
  </span>
  <!-- 다른 컴포넌트에서 같은 상태인데 다른 색상 사용 위험 -->
</template>

Rule 9: 금지사항 (Anti-patterns 코드 예시)

1. 인라인 스타일 사용 금지

<!-- DON'T -->
<div :style="{ marginTop: '16px', color: '#3b82f6' }">콘텐츠</div>

<!-- DO -->
<div class="mt-4 text-blue-500">콘텐츠</div>

예외: JS로 계산된 동적 수치(드래그 위치, 애니메이션 좌표 등)는 인라인 스타일 허용.

2. 동적 클래스 문자열 생성 금지

// DON'T
const cls = `bg-${color}-500`
const size = `text-${props.size}`

// DO
const COLOR_MAP = { blue: 'bg-blue-500', red: 'bg-red-500' } as const
const cls = COLOR_MAP[color]

3. !important 남용 금지

<!-- DON'T -->
<div class="!mt-0 !p-0 !text-sm">강제 오버라이드</div>

<!-- DO: 클래스 순서나 cn()으로 충돌 해결 -->
<div :class="cn('mt-4 p-4', props.class)">올바른 병합</div>

!important가 필요한 상황은 대부분 클래스 적용 순서 문제. cn()이나 구조 변경으로 해결한다.

4. @apply 과용 금지

/* DON'T: 단일 유틸리티 추상화 */
.text-blue { @apply text-blue-500; }
.mt-small { @apply mt-2; }
.flex-center { @apply flex items-center justify-center; }

/* DO: 3개 이상 컴포넌트에서 공유하는 복합 패턴에만 사용 */
.input-base {
  @apply w-full rounded-md border border-input bg-background px-3 py-2
         text-sm ring-offset-background
         focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
}

5. Magic number 임의값 남용 금지

<!-- DON'T -->
<div class="mt-[17px] w-[213px] text-[13.5px] bg-[#1a1a2e]">
  디자인 시스템 무시
</div>

<!-- DO: 디자인 토큰 또는 가장 가까운 표준값 사용 -->
<div class="mt-4 w-52 text-sm bg-background">
  디자인 시스템 준수
</div>

임의값([...])은 디자인 토큰에 없는 값이 정말로 필요한 경우에만 최소한으로 사용한다. 반복되면 @theme에 토큰으로 등록한다.

6. 하드코딩 색상 금지

<!-- DON'T -->
<div class="bg-[#1a1a2e] text-[#eaeaea]">하드코딩 색상</div>

<!-- DO: CSS 변수 또는 Tailwind 색상 팔레트 사용 -->
<div class="bg-background text-foreground">CSS 변수 사용</div>

7. 불필요한 반응형 클래스 반복 금지

<!-- DON'T: 값이 변하지 않는 브레이크포인트에서 동일  반복 -->
<p class="text-xs sm:text-xs md:text-sm lg:text-sm xl:text-base">텍스트</p>

<!-- DO: 값이 변경되는 브레이크포인트만 명시 -->
<p class="text-xs md:text-sm xl:text-base">텍스트</p>

자주 하는 실수 TOP 5

1. 문자열 보간으로 동적 클래스 생성

가장 흔하고 치명적인 실수. TailwindCSS v4의 정적 분석에 감지되지 않아 프로덕션에서 스타일이 누락된다.

// 실수
const colorClass = `text-${props.color}-500`  // 빌드 시 누락

// 해결
const COLOR_MAP: Record<string, string> = {
  blue: 'text-blue-500',
  red: 'text-red-500',
} as const
const colorClass = computed(() => COLOR_MAP[props.color] ?? 'text-blue-500')

2. Desktop-first로 반응형 작성

큰 화면 기준으로 먼저 작성하고 max-*로 축소하면 모바일에서 예상치 못한 스타일 충돌이 발생한다.

<!-- 실수 -->
<div class="grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1">

<!-- 해결: 항상 Mobile-first -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">

3. CSS 변수 대신 dark: prefix 남발

모든 요소에 dark: 변형을 달면 코드량이 2배로 늘어나고 관리가 어려워진다.

<!-- 실수 -->
<div class="bg-white text-black dark:bg-slate-900 dark:text-white">

<!-- 해결: CSS 변수 사용 -->
<div class="bg-background text-foreground">

4. cn() 없이 외부 클래스를 문자열로 합침

cn() 없이 단순 문자열 연결을 하면 Tailwind 클래스 간 충돌(예: p-4p-2)이 해결되지 않아 예측 불가능한 스타일이 적용된다.

<!-- 실수 -->
<div :class="`base-classes ${props.class}`">

<!-- 해결 -->
<div :class="cn('base-classes', props.class)">

5. focus 대신 focus-visible 사용 누락

focus:는 마우스 클릭에도 포커스 링이 표시되어 시각적 노이즈를 만든다. focus-visible:은 키보드 탐색 시에만 표시된다.

<!-- 실수 -->
<button class="focus:ring-2 focus:ring-blue-500 focus:outline-none">

<!-- 해결 -->
<button class="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">