본문 바로가기

개발 공부

shadcn/ui (2) example) Button · Card

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