1002 lines
26 KiB
Markdown
1002 lines
26 KiB
Markdown
---
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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 설정**
|
|
|
|
```json
|
|
// .prettierrc
|
|
{
|
|
"plugins": ["prettier-plugin-tailwindcss"],
|
|
"tailwindConfig": "./tailwind.config.ts",
|
|
"tailwindFunctions": ["cn", "cva", "clsx", "twMerge"]
|
|
}
|
|
```
|
|
|
|
> `tailwindFunctions`에 `cn`, `cva` 등을 등록해야 유틸 함수 내부의 클래스도 자동 정렬된다.
|
|
|
|
---
|
|
|
|
### Rule 2: Mobile-first 반응형 (기본=모바일, sm/md/lg/xl 순서)
|
|
|
|
기본 스타일은 모바일 기준으로 작성하고, 더 큰 화면에서 순서대로 덮어쓴다.
|
|
|
|
```
|
|
기본(base) -> 0px~ : 모바일 (prefix 없음)
|
|
sm -> 640px~ : 대형 모바일, 소형 태블릿
|
|
md -> 768px~ : 태블릿
|
|
lg -> 1024px~ : 소형 데스크탑
|
|
xl -> 1280px~ : 데스크탑
|
|
2xl -> 1536px~ : 대형 모니터
|
|
```
|
|
|
|
**DO**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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>
|
|
```
|
|
|
|
**반응형 타이포그래피 패턴**
|
|
|
|
```vue
|
|
<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 클래스 충돌 해결을 자동으로 처리한다.
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 클래스 충돌 시 예측 불가 (p-4와 p-2가 동시 적용) -->
|
|
<div :class="`rounded-lg border p-4 ${props.class}`">
|
|
<slot />
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
#### cva (class-variance-authority) 패턴
|
|
|
|
변형(variant)이 있는 컴포넌트는 반드시 `cva`로 정의한다.
|
|
|
|
**DO**
|
|
|
|
```typescript
|
|
// 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>
|
|
```
|
|
|
|
```vue
|
|
<!-- 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**
|
|
|
|
```vue
|
|
<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 사용 기준
|
|
|
|
`@apply`는 **3개 이상의 컴포넌트에서 반복되는 복합 패턴**에만 허용한다.
|
|
|
|
**DO**
|
|
|
|
```css
|
|
/* 허용: 여러 컴포넌트에서 반복 사용되는 복합 패턴 */
|
|
.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**
|
|
|
|
```css
|
|
/* 금지: 단일 클래스 추상화 -> 의미 없는 간접참조 */
|
|
.text-blue { @apply text-blue-500; }
|
|
.mt-small { @apply mt-2; }
|
|
.flex-center { @apply flex items-center justify-center; }
|
|
```
|
|
|
|
#### shadcn-vue 커스터마이징
|
|
|
|
shadcn-vue 컴포넌트 원본 파일을 직접 수정하지 않는다. 래퍼 컴포넌트로 확장한다.
|
|
|
|
**DO**
|
|
|
|
```vue
|
|
<!-- 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**
|
|
|
|
```vue
|
|
<!-- 금지: shadcn-vue 원본 파일(components/ui/button/Button.vue)을 직접 수정 -->
|
|
<!-- 업데이트 시 충돌 발생, 변경 추적 불가 -->
|
|
```
|
|
|
|
---
|
|
|
|
### Rule 4: 다크모드 (CSS 변수 우선, dark: prefix 보완용)
|
|
|
|
CSS 변수 기반 테마를 우선 사용한다. `dark:` prefix는 CSS 변수로 처리할 수 없는 경우에만 보완 용도로 사용한다.
|
|
|
|
**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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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>
|
|
```
|
|
|
|
**다크모드 토글 구현**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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}` 문법을 사용한다.
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```css
|
|
/* 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; }
|
|
}
|
|
```
|
|
|
|
```vue
|
|
<template>
|
|
<!-- @theme에서 정의한 토큰은 일반 Tailwind 클래스처럼 사용 가능 -->
|
|
<div class="font-sans text-brand-500 shadow-soft animate-fade-in p-18">
|
|
커스텀 토큰 사용 예시
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
**DON'T**
|
|
|
|
```css
|
|
/* 금지: v4에서 @theme 대신 옛 방식(tailwind.config.ts extend)으로 토큰 정의 */
|
|
/* tailwind.config.ts의 theme.extend는 여전히 동작하지만 @theme이 권장 방식 */
|
|
```
|
|
|
|
#### @container 쿼리
|
|
|
|
뷰포트가 아닌 **부모 컨테이너 크기** 기반으로 반응형 스타일을 적용한다. 사이드바, 카드 리스트 등 배치 위치에 따라 크기가 달라지는 컴포넌트에 유용하다.
|
|
|
|
**DO**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 금지: 뷰포트 반응형으로 컨테이너 내부 레이아웃 제어 -->
|
|
<!-- 사이드바에 배치하면 의도와 다르게 동작 -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
<!-- 뷰포트가 lg여도 사이드바 안에서는 공간이 좁다 -->
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
### Rule 7: 성능 최적화 (동적 클래스 생성 금지, 클래스 매핑 패턴)
|
|
|
|
TailwindCSS v4는 소스 코드를 정적 분석하여 사용된 클래스만 번들에 포함한다. 런타임에 문자열을 조합하면 빌드 시 해당 클래스가 누락된다.
|
|
|
|
#### 동적 클래스 생성 금지 (Critical)
|
|
|
|
**DO**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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 변수로 중앙 관리한다.
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 토큰 참조: 임의값(arbitrary value) 문법으로 CSS 변수 활용 -->
|
|
<header class="sticky top-0 z-[var(--z-sticky)] h-[var(--header-height)]">
|
|
<!-- 헤더 내용 -->
|
|
</header>
|
|
</template>
|
|
```
|
|
|
|
#### 상태별 클래스 매핑
|
|
|
|
반복되는 상태 스타일을 타입 안전한 매핑 객체로 관리한다.
|
|
|
|
**DO**
|
|
|
|
```typescript
|
|
// 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
|
|
```
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```vue
|
|
<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. 인라인 스타일 사용 금지
|
|
|
|
```vue
|
|
<!-- DON'T -->
|
|
<div :style="{ marginTop: '16px', color: '#3b82f6' }">콘텐츠</div>
|
|
|
|
<!-- DO -->
|
|
<div class="mt-4 text-blue-500">콘텐츠</div>
|
|
```
|
|
|
|
> 예외: JS로 계산된 동적 수치(드래그 위치, 애니메이션 좌표 등)는 인라인 스타일 허용.
|
|
|
|
#### 2. 동적 클래스 문자열 생성 금지
|
|
|
|
```typescript
|
|
// 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 남용 금지
|
|
|
|
```vue
|
|
<!-- 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 과용 금지
|
|
|
|
```css
|
|
/* 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 임의값 남용 금지
|
|
|
|
```vue
|
|
<!-- 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. 하드코딩 색상 금지
|
|
|
|
```vue
|
|
<!-- DON'T -->
|
|
<div class="bg-[#1a1a2e] text-[#eaeaea]">하드코딩 색상</div>
|
|
|
|
<!-- DO: CSS 변수 또는 Tailwind 색상 팔레트 사용 -->
|
|
<div class="bg-background text-foreground">CSS 변수 사용</div>
|
|
```
|
|
|
|
#### 7. 불필요한 반응형 클래스 반복 금지
|
|
|
|
```vue
|
|
<!-- 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의 정적 분석에 감지되지 않아 프로덕션에서 스타일이 누락된다.
|
|
|
|
```typescript
|
|
// 실수
|
|
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-*`로 축소하면 모바일에서 예상치 못한 스타일 충돌이 발생한다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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배로 늘어나고 관리가 어려워진다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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-4`와 `p-2`)이 해결되지 않아 예측 불가능한 스타일이 적용된다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<div :class="`base-classes ${props.class}`">
|
|
|
|
<!-- 해결 -->
|
|
<div :class="cn('base-classes', props.class)">
|
|
```
|
|
|
|
### 5. focus 대신 focus-visible 사용 누락
|
|
|
|
`focus:`는 마우스 클릭에도 포커스 링이 표시되어 시각적 노이즈를 만든다. `focus-visible:`은 키보드 탐색 시에만 표시된다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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">
|
|
```
|