Tailwind CSS와 shadcn/ui를 사용하여 일관되고 아름다운 UI를 구축하는 방법을 학습합니다.
Tailwind CSS는 유틸리티 우선(Utility-First) CSS 프레임워크입니다. 미리 정의된 클래스를 조합하여 빠르게 UI를 구축합니다. CSS 파일을 별도로 관리할 필요 없이 HTML에서 직접 스타일링합니다. 이커머스 프로젝트에서 생산성을 크게 높여줍니다.
기존 CSS
/* styles.css */
.card {
padding: 1rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* component.tsx */
<div className="card">...</div>Tailwind CSS
/* CSS 파일 불필요 */ /* component.tsx */ <div className="p-4 bg-white rounded-lg shadow"> ... </div>
🚀 빠른 개발 속도
CSS 파일 왔다갔다 없이 HTML에서 바로 스타일링
📦 작은 번들
사용한 클래스만 빌드에 포함 (PurgeCSS)
🎨 일관된 디자인
디자인 시스템 기반의 제한된 선택지
🔧 커스터마이징
tailwind.config.js로 완전한 커스터마이징
| 컴포넌트 | Tailwind 클래스 예시 |
|---|---|
| 상품 카드 | rounded-lg shadow hover:shadow-lg |
| 가격 표시 | text-2xl font-bold text-red-600 |
| 버튼 | bg-blue-600 hover:bg-blue-700 px-4 py-2 |
주의사항
Next.js와 Tailwind
Next.js는 Tailwind CSS를 공식 지원합니다. create-next-app에서 Tailwind 옵션을 선택하면 자동 설정됩니다.
자주 사용하는 Tailwind 유틸리티 클래스를 알아봅니다. 이 클래스들을 조합하면 대부분의 UI를 구현할 수 있습니다. 외우지 않아도 됩니다. 자주 쓰다 보면 자연스럽게 익숙해집니다.
// Flexbox <div className="flex items-center justify-between gap-4"> // Grid <div className="grid grid-cols-3 gap-4"> // Container <div className="container mx-auto px-4"> // 반응형 그리드 (이커머스 상품 목록) <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
// Padding p-4 // 모든 방향 1rem px-4 // 좌우 1rem py-2 // 상하 0.5rem pt-8 // 상단 2rem // Margin m-4 // 모든 방향 mx-auto // 좌우 auto (가운데 정렬) mt-8 // 상단 mb-4 // 하단 // Gap (flex/grid) gap-4 // 모든 방향 gap-x-4 // 가로 gap-y-2 // 세로
// 크기 text-sm // 0.875rem text-base // 1rem text-lg // 1.125rem text-xl // 1.25rem text-2xl // 1.5rem // 굵기 font-normal // 400 font-medium // 500 font-semibold // 600 font-bold // 700 // 색상 text-gray-500 text-primary text-muted-foreground
| 접두사 | 최소 너비 | 용도 |
|---|---|---|
| sm: | 640px | 큰 모바일 |
| md: | 768px | 태블릿 |
| lg: | 1024px | 노트북 |
| xl: | 1280px | 데스크톱 |
shadcn/ui는 복사해서 사용하는 컴포넌트 컬렉션입니다. npm 패키지가 아니라 소스 코드를 직접 프로젝트에 추가합니다. Radix UI 기반으로 접근성이 뛰어나고 Tailwind로 스타일링됩니다.
📋 복사 기반
컴포넌트 소스를 프로젝트에 복사하여 완전한 제어권 확보
🎨 Radix UI 기반
접근성이 뛰어난 Radix UI 프리미티브 사용
💅 Tailwind 스타일링
Tailwind CSS로 스타일링되어 커스터마이징 용이
🌙 다크 모드
CSS 변수 기반 테마로 다크 모드 기본 지원
# 초기화 npx shadcn@latest init # 컴포넌트 추가 npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add input npx shadcn@latest add form # 여러 컴포넌트 한번에 npx shadcn@latest add button card input form
components/ ├── ui/ # shadcn/ui 컴포넌트 │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ └── ... └── ... lib/ └── utils.ts # cn() 유틸리티 함수 app/ └── globals.css # CSS 변수 (테마)
왜 복사 기반인가?
이커머스에서 자주 사용하는 shadcn/ui 컴포넌트를 알아봅니다. 각 컴포넌트는 Radix UI 기반으로 접근성이 뛰어납니다.
import { Button } from "@/components/ui/button";
// 기본
<Button>클릭</Button>
// 변형
<Button variant="outline">테두리</Button>
<Button variant="ghost">고스트</Button>
<Button variant="destructive">삭제</Button>
// 크기
<Button size="sm">작게</Button>
<Button size="lg">크게</Button>
// 아이콘과 함께
<Button>
<ShoppingCart className="mr-2 h-4 w-4" />
장바구니
</Button>import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card";
<Card>
<CardHeader>
<CardTitle>상품명</CardTitle>
<CardDescription>상품 설명</CardDescription>
</CardHeader>
<CardContent>
<p>가격: 29,000원</p>
</CardContent>
<CardFooter>
<Button>구매하기</Button>
</CardFooter>
</Card>import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
<Dialog>
<DialogTrigger asChild>
<Button>빠른 보기</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>상품 상세</DialogTitle>
<DialogDescription>상품 정보를 확인하세요</DialogDescription>
</DialogHeader>
{/* 내용 */}
</DialogContent>
</Dialog>import {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
// 이커머스 장바구니 사이드바
<Sheet>
<SheetTrigger asChild>
<Button>장바구니</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>장바구니</SheetTitle>
</SheetHeader>
{/* 장바구니 내용 */}
</SheetContent>
</Sheet>CSS 변수를 사용한 테마 시스템과 다크 모드 구현 방법입니다. shadcn/ui는 CSS 변수 기반으로 테마를 관리하여 쉽게 커스터마이징할 수 있습니다.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
/* ... */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... */
}
}// next-themes 설치
npm install next-themes
// providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
);
}
// 토글 버튼
'use client';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-5 w-5 dark:hidden" />
<Moon className="h-5 w-5 hidden dark:block" />
</Button>
);
}CSS 변수 사용법
bg-background - 배경색text-foreground - 텍스트색border-border - 테두리색cn() 함수로 조건부 클래스를 깔끔하게 관리합니다. clsx와 tailwind-merge를 결합하여 클래스 충돌을 자동으로 해결합니다.
// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// clsx: 조건부 클래스 결합
// twMerge: Tailwind 클래스 충돌 해결import { cn } from "@/lib/utils";
// 조건부 클래스
<div className={cn(
"p-4 rounded-lg",
isActive && "bg-primary text-primary-foreground",
isDisabled && "opacity-50 cursor-not-allowed"
)}>
// 기본값 + 오버라이드
interface ButtonProps {
className?: string;
}
function MyButton({ className }: ButtonProps) {
return (
<button className={cn(
"px-4 py-2 bg-blue-500 text-white rounded",
className // 외부에서 전달된 클래스로 오버라이드 가능
)}>
버튼
</button>
);
}
// 사용
<MyButton className="bg-red-500" /> // 빨간 버튼// cva (class-variance-authority) 사용
import { cva, type VariantProps } from "class-variance-authority";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive text-destructive-foreground",
outline: "border border-input",
},
},
defaultVariants: {
variant: "default",
},
}
);
interface BadgeProps extends VariantProps<typeof badgeVariants> {
className?: string;
}
function Badge({ variant, className }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} />;
}
// 이커머스 상품 뱃지 사용 예시
<Badge variant="secondary">신상품</Badge>
<Badge variant="destructive">품절</Badge>
<Badge variant="default">베스트</Badge>Tailwind와 shadcn/ui로 상품 카드 컴포넌트를 만듭니다. 실제 이커머스에서 사용할 수 있는 완성도 높은 컴포넌트입니다.
import Image from "next/image";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Heart, ShoppingCart } from "lucide-react";
import { cn } from "@/lib/utils";
interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
originalPrice?: number;
image: string;
isNew?: boolean;
isSoldOut?: boolean;
};
}
export function ProductCard({ product }: ProductCardProps) {
const discount = product.originalPrice
? Math.round((1 - product.price / product.originalPrice) * 100)
: 0;
return (
<Card className="group overflow-hidden">
{/* 이미지 영역 */}
<div className="relative aspect-square">
<Image
src={product.image}
alt={product.name}
fill
className={cn(
"object-cover transition-transform group-hover:scale-105",
product.isSoldOut && "opacity-50"
)}
/>
{/* 배지 */}
<div className="absolute top-2 left-2 flex gap-1">
{product.isNew && <Badge>NEW</Badge>}
{discount > 0 && <Badge variant="destructive">{discount}%</Badge>}
</div>
{/* 찜하기 버튼 */}
<Button
size="icon"
variant="ghost"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Heart className="h-5 w-5" />
</Button>
{/* 품절 오버레이 */}
{product.isSoldOut && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<span className="text-white font-bold">SOLD OUT</span>
</div>
)}
</div>
{/* 정보 영역 */}
<CardContent className="p-4">
<h3 className="font-medium line-clamp-2 mb-2">{product.name}</h3>
<div className="flex items-baseline gap-2">
<span className="text-lg font-bold">
{product.price.toLocaleString()}원
</span>
{product.originalPrice && (
<span className="text-sm text-muted-foreground line-through">
{product.originalPrice.toLocaleString()}원
</span>
)}
</div>
<Button
className="w-full mt-4"
disabled={product.isSoldOut}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{product.isSoldOut ? '품절' : '장바구니'}
</Button>
</CardContent>
</Card>
);
}스타일링 베스트 프랙티스