Button 컴포넌트
먼저 shadcn/ui에서 기본으로 제공하는 Button 코드를 다시 한 번 형태 위주로만 살펴보겠습니다.
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
1. cva로 variant/size를 관리하는 패턴
핵심은 buttonVariants입니다.
const buttonVariants = cva("공통 클래스", {
variants: {
variant: { ... },
size: { ... },
},
defaultVariants: {
variant: "default",
size: "default",
},
})
- cva는 class-variance-authority 라이브러리에서 제공하는 함수입니다.
- Tailwind 클래스를 variant / size 같은 옵션에 따라 깔끔하게 관리할 수 있게 해줍니다.
- 나중에 사용할 때는 buttonVariants({ variant: "outline", size: "lg" })처럼 호출합니다.
이 패턴 덕분에 컴포넌트 사용 코드는 매우 단순해집니다
<Button variant="outline" size="sm">Outline Button</Button>
- 내부에서는 buttonVariants가 적절한 Tailwind 클래스 문자열을 만들어주고, cn() 유틸로 추가 클래스를 합쳐줍니다.
2. asChild + Slot으로 태그 타입 바꾸기
이 부분이 shadcn/ui Button의 또 다른 핵심입니다.
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
- 기본적으로는 <button> 태그를 쓰지만,
- asChild가 true이면 Radix의 <Slot> 컴포넌트를 사용합니다.
이를 활용하면
<Button asChild>
<a href="/dashboard">대시보드 이동</a>
</Button>
처럼 실제 DOM은 <a> 태그인데, 스타일은 Button을 그대로 재사용할 수 있습니다.
Next.js Link와 조합할 때도 매우 유용합니다.
import Link from "next/link"
<Button asChild>
<Link href="/dashboard">대시보드</Link>
</Button>
Card 컴포넌트 구조와 컴포지션 패턴
Card는 Button보다 단순하지만, 컴포넌트 컴포지션(합성) 패턴을 보여주는 좋은 예시입니다.
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
- 모든 서브 컴포넌트가 div + Tailwind + cn() 조합
- Card / CardHeader / CardContent / CardFooter 등으로 역할 분리
- 조합해서 하나의 카드 UI를 만드는 레이아웃 슬랏(s) 역할을 함
실제 사용 코드는 이렇게 됩니다.
<Card>
<CardHeader>
<CardTitle>프로 플랜</CardTitle>
<CardDescription>
팀 단위 사용자를 위한 유료 구독 플랜입니다.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">₩19,000 / 월</p>
<p className="text-sm text-muted-foreground mt-1">
14일 무료 체험 후 언제든 해지 가능합니다.
</p>
</CardContent>
<CardFooter className="justify-end">
<Button>시작하기</Button>
</CardFooter>
</Card>
예제
액션 카드
// app/(marketing)/_components/plan-card.tsx
import { Button } from "@/components/ui/button"
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card"
interface PlanCardProps {
name: string
description: string
price: string
highlight?: boolean
}
export function PlanCard({ name, description, price, highlight }: PlanCardProps) {
return (
<Card className={highlight ? "border-primary shadow-lg" : ""}>
<CardHeader>
<CardTitle>{name}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{price}</p>
<p className="text-xs text-muted-foreground mt-2">
부가세 별도 · 언제든 해지 가능
</p>
</CardContent>
<CardFooter className="justify-end">
<Button size="sm">이 플랜 선택</Button>
</CardFooter>
</Card>
)
}
페이지에서 여러 개의 플랜 카드로 렌더링할 수도 있습니다.
// app/(marketing)/pricing/page.tsx
import { PlanCard } from "./_components/plan-card"
const plans = [
{
name: "Basic",
description: "개인 사용자용 기본 플랜",
price: "₩0",
},
{
name: "Pro",
description: "팀 협업 및 고급 기능 제공",
price: "₩19,000 / 월",
highlight: true,
},
]
export default function PricingPage() {
return (
<div className="container mx-auto py-12">
<h1 className="mb-8 text-3xl font-bold">요금제</h1>
<div className="grid gap-6 md:grid-cols-2">
{plans.map(plan => (
<PlanCard key={plan.name} {...plan} />
))}
</div>
</div>
)
}
asChild를 활용한 링크 버튼 패턴
Link와 함께 쓰기
import Link from "next/link"
import { Button } from "@/components/ui/button"
export function HeroSection() {
return (
<section className="space-y-4 py-10">
<h1 className="text-4xl font-bold tracking-tight">
AI 스토리 메이킹을 더 쉽게.
</h1>
<p className="text-muted-foreground">
캐릭터, 세계관, 스토리를 한 번에 관리할 수 있는 크리에이터 도구입니다.
</p>
<div className="flex gap-3">
<Button asChild>
<Link href="/signup">지금 시작하기</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/docs">문서 보기</Link>
</Button>
</div>
</section>
)
}
- DOM에는 <a> 태그가 렌더링되지만,
- 스타일/인터랙션은 Button 그대로 사용
이 패턴을 프로젝트 전체에 통일해서 사용하면
- CTA 버튼 스타일이 일관되고
- a / Link / button 구분은 접근성 기준에 맞게 유지
'개발 공부' 카테고리의 다른 글
| 연결 리스트(Node) (0) | 2025.12.06 |
|---|---|
| Awaited와 ReturnType을 활용한 타입 자동 추론 (0) | 2025.11.19 |
| shadcn/ui (1) (0) | 2025.11.16 |
| Prisma CRUD 예시 (0) | 2025.11.12 |
| Prisma DB 연결 (0) | 2025.11.09 |