콘텐츠로 이동

15. 컴포넌트 해부

“내 코드”라고 하는데 읽을 수 없으면 소유한 게 아니다

button.tsx는 60줄 남짓이다. 전부 이해할 수 있는 분량이고, 여기 쓰인 패턴이 나머지 70개 컴포넌트에도 똑같이 나온다. 한 번 읽어두면 어떤 컴포넌트든 열어서 고칠 수 있다.

import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(base, { variants: { variant, size }, defaultVariants })
function Button({ className, variant, size, render, ...props }) {
return <Component className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
export { Button, buttonVariants }

세 줄짜리 구조다. 나머지는 클래스 문자열의 길이일 뿐이다.

flowchart LR
    A["cva<br/>클래스 조합 정의"] --> B["cn<br/>사용자 오버라이드 병합"]
    B --> C["...props<br/>나머지는 통과"]
    C --> D["프리미티브 / DOM"]

    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class A key
    class B ok
    class C,D mute

buttonVariants함께 export하는 것이 중요하다. 이유는 뒤에서 본다.

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap " +
"rounded-md text-sm font-medium transition-all shrink-0 outline-none " +
"disabled:pointer-events-none disabled:opacity-50 " +
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] " +
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive " +
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
{ /* … */ }
)

11장에서 본 그룹으로 읽으면 이렇게 나뉜다 — 레이아웃 / 타이포 / 테두리 / 상태 / 애니메이션.

1. focus-visible: 이지 focus: 가 아니다

마우스 클릭에는 링이 안 나오고 키보드 탭 이동에만 나온다. 접근성과 미관을 둘 다 잡는 표준 해법이다. (18장)

2. aria-invalid: 변형

aria-invalid="true"만 붙이면 테두리가 자동으로 destructive가 된다. 스타일을 위해 별도 prop을 만들 필요가 없다.

3. [&_svg]:pointer-events-none

자식 SVG에 적용되는 임의 셀렉터. 아이콘이 클릭을 가로채는 문제를 원천 차단한다. 아이콘 버튼에서 “가끔 클릭이 안 먹는” 버그의 정체가 대개 이것이다.

variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground 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',
xs: 'h-7 rounded-sm px-2 text-xs',
sm: 'h-8 rounded-md px-3',
lg: 'h-10 rounded-md px-6',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },

bg-primary, text-primary-foreground, border, bg-accentbg-zinc-900이 단 한 번도 안 나온다.

그래서 17장에서 토큰만 갈아끼우면 전부 따라 바뀐다. 12장에서 세운 “컴포넌트 안에서는 원시 색을 쓰지 않는다”는 규칙이 실제로 지켜진 결과물이 이 파일이다.

hover:bg-primary/90
  • 투명도 수식어다. --primary 색을 90% 불투명도로 쓴다
  • hover:bg-primary-600 같은 별도 토큰을 만들 필요가 없다
  • 어떤 테마를 넣어도 “조금 연해진 primary”가 자동으로 나온다

단점도 있다. 배경이 어두우면 투명도로 밝아지고, 밝으면 어두워진다. 정밀한 호버 색이 필요한 디자인 시스템에서는 --primary-hover 토큰을 따로 두기도 한다.

function Button({
className,
variant,
size,
render,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> &
{ render?: React.ReactElement }) {
return (
<BaseButton
data-slot="button"
render={render}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
  • React.ComponentProps<'button'><button>이 받는 모든 속성을 그대로 받는다
  • VariantProps<typeof buttonVariants> — cva에서 타입을 자동 추출한다
  • ...props 통과 — onClick, type, aria-label, form 전부 그냥 동작한다
const Button = React.forwardRef<HTMLButtonElement, Props>(
({ className, ...props }, ref) => <button ref={ref} {...props} />
)
Button.displayName = 'Button'

data-slot — 수정하지 않고 예외 만들기

섹션 제목: “data-slot — 수정하지 않고 예외 만들기”
<BaseButton data-slot="button" ... />

모든 shadcn 컴포넌트가 data-slot 속성을 갖는다. 부모에서 자식의 특정 부분만 겨냥할 수 있다.

// 이 카드 안의 버튼만 전부 작게
<Card className="[&_[data-slot=button]]:h-8">
<Button>저장</Button>
<Button>취소</Button>
</Card>

남용하면 읽기 어려워지지만, 컴포넌트를 수정하지 않고 예외를 만드는 탈출구로 유용하다. 16장의 “ui/는 되도록 손대지 않는다” 규칙을 지키는 데 도움이 된다.

buttonVariants export — 버튼이 아닌 것을 버튼처럼

섹션 제목: “buttonVariants export — 버튼이 아닌 것을 버튼처럼”

버튼처럼 보여야 하지만 <button>이 아니어야 할 때가 있다.

import Link from 'next/link'
import { buttonVariants } from '@/components/ui/button'
// 링크를 버튼처럼
<Link href="/pricing" className={buttonVariants({ variant: 'outline' })}>
요금제 보기
</Link>

<Button render={<Link />}>로 하는 것보다 이쪽이 권장된다. 클래스만 필요한 경우에 컴포넌트 합성 오버헤드를 만들 이유가 없기 때문이다.

단일 컴포넌트가 아니라 여러 조각의 묶음으로 제공되는 것들이 있다.

<Card>
<CardHeader>
<CardTitle>월간 리포트</CardTitle>
<CardDescription>2026년 7월</CardDescription>
<CardAction><Button variant="ghost" size="icon"></Button></CardAction>
</CardHeader>
<CardContent>
<Chart data={data} />
</CardContent>
<CardFooter>
<Button>내보내기</Button>
</CardFooter>
</Card>

props로 title, description, footer를 받는 대신 구조를 열어둔다.

// Base UI 방식 — 렌더할 엘리먼트를 직접 넘긴다
<Tabs.Trigger render={<a href="/settings" />}>설정</Tabs.Trigger>
// Radix 방식 (구버전 자료) — 자식으로 넘긴다
<Tabs.Trigger asChild>
<a href="/settings">설정</a>
</Tabs.Trigger>
  • 용도: 동작은 그대로 두고 렌더되는 태그만 바꾸고 싶을 때
  • 예: 탭 트리거를 실제 링크로, 버튼을 <label>
  • render는 병합 지점이 명시적이라 디버깅이 쉽다
  • 구조는 세 줄이다: cva 정의 → cn 병합 → props 통과
  • 원시 색이 한 번도 안 나온다. 전부 의미 토큰이다 → 테마 교체가 가능한 이유
  • focus-visible:, aria-invalid:, [&_svg]:접근성 속성이 스타일 훅이다
  • React 19에서 forwardRef는 필요 없다. ref가 그냥 prop
  • data-slot으로 컴포넌트를 수정하지 않고 예외를 만들 수 있다
  • buttonVariants export → 링크를 버튼처럼 보이게 할 때
  • 합성 컴포넌트는 구조를 열어둬서 변형 요구를 흡수한다