Next.js 이론 11강

스타일링 - Tailwind CSS + shadcn/ui

Tailwind CSS와 shadcn/ui를 사용하여 일관되고 아름다운 UI를 구축하는 방법을 학습합니다.

1. Tailwind CSS 소개

Tailwind CSS는 유틸리티 우선(Utility-First) CSS 프레임워크입니다. 미리 정의된 클래스를 조합하여 빠르게 UI를 구축합니다. CSS 파일을 별도로 관리할 필요 없이 HTML에서 직접 스타일링합니다. 이커머스 프로젝트에서 생산성을 크게 높여줍니다.

기존 CSS vs Tailwind

기존 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>

Tailwind 장점

🚀 빠른 개발 속도

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

주의사항

  • • 클래스가 길어지면 가독성 저하
  • • 반복되는 스타일은 컴포넌트로 추출
  • • @apply는 최소한으로 사용

Next.js와 Tailwind

Next.js는 Tailwind CSS를 공식 지원합니다. create-next-app에서 Tailwind 옵션을 선택하면 자동 설정됩니다.

2. 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">

간격 (Spacing)

// 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데스크톱

3. shadcn/ui 소개

shadcn/ui는 복사해서 사용하는 컴포넌트 컬렉션입니다. npm 패키지가 아니라 소스 코드를 직접 프로젝트에 추가합니다. Radix UI 기반으로 접근성이 뛰어나고 Tailwind로 스타일링됩니다.

shadcn/ui 특징

📋 복사 기반

컴포넌트 소스를 프로젝트에 복사하여 완전한 제어권 확보

🎨 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 변수 (테마)

왜 복사 기반인가?

  • • npm 패키지와 달리 소스 코드 직접 수정 가능
  • • 프로젝트 요구사항에 맞게 자유롭게 커스터마이징
  • • 버전 업데이트에 의존하지 않음

4. 주요 shadcn/ui 컴포넌트

이커머스에서 자주 사용하는 shadcn/ui 컴포넌트를 알아봅니다. 각 컴포넌트는 Radix UI 기반으로 접근성이 뛰어납니다.

Button

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>

Card

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>

Dialog (모달)

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>

Sheet (사이드 패널)

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>

5. 테마와 다크 모드

CSS 변수를 사용한 테마 시스템과 다크 모드 구현 방법입니다. shadcn/ui는 CSS 변수 기반으로 테마를 관리하여 쉽게 커스터마이징할 수 있습니다.

CSS 변수 (globals.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 - 테두리색
  • • 테마가 바뀌면 자동으로 색상 변경

6. cn() 유틸리티와 조건부 스타일

cn() 함수로 조건부 클래스를 깔끔하게 관리합니다. clsx와 tailwind-merge를 결합하여 클래스 충돌을 자동으로 해결합니다.

cn() 함수

// 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" />  // 빨간 버튼

variants 패턴

// 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>

7. 이커머스 예제: 상품 카드

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>
  );
}

핵심 정리

Tailwind로 빠른 스타일링
shadcn/ui로 일관된 컴포넌트
cn()으로 조건부 스타일 관리
CSS 변수로 테마/다크모드

스타일링 베스트 프랙티스

  • • 반복되는 스타일은 컴포넌트로 추출
  • • group/peer로 부모-자식 상호작용
  • • transition으로 부드러운 애니메이션
  • • 반응형은 모바일 퍼스트로 설계
  • • cn() 함수로 조건부 스타일 관리