거의 매일
Button · Input · Label · Card ·
Dialog · Select · Table · Badge
--primary가 “브랜드 색은 이것”이라는 결정이라면,button.tsx는 “outline 버튼이란 이런 것”이라는 결정이다. 둘 다 테마다.
variant변형default / outline / ghost 처럼. 크기와는 별개의 축이다.`cva`class-variance-authority`cn`class names`clsx`cn 의 절반을 담당한다.`tailwind-merge`p-2 p-4 처럼 충돌하는 Tailwind 클래스에서 뒤엣것만 남기는 라이브러리. cn 의 나머지 절반.토큰만으로는 정의되지 않는 결정들이 있다.
--spacing이 아니라 컴포넌트가 정한다accent 배경으로 바뀐다 — 어떤 토큰을 쓸지도 결정이다이 결정들이 사는 곳이 components/ui/button.tsx다.
shadcn/ui가 소유권을 넘겨 준 게 바로 이 층이고, 그래서 이건 관리 대상 리소스다.
button.tsx 읽기받아 온 파일에서 군더더기를 걷어내면 이 구조다.
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-2 focus-visible:ring-ring \ disabled:pointer-events-none disabled:opacity-50", { variants: { // ② 성격 축 variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "bg-destructive text-white hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, // ③ 크기 축 size: { sm: "h-8 px-3 text-xs", default: "h-9 px-4 py-2", lg: "h-10 px-6", icon: "size-9", }, }, // ④ 아무것도 안 주면 이것 defaultVariants: { variant: "default", size: "default" }, })
function Button({ className, variant, size, ...props }) { return <button className={cn(buttonVariants({ variant, size, className }))} {...props} />}여기서 확인할 것 하나 — bg-zinc-900 같은 원시 색이 단 한 번도 안 나온다.
전부 bg-primary, border-input, ring-ring 같은 의미 토큰이다.
2장에서 본 테마 교체가 가능한 유일한 이유다.
길어 보이지만 사실 몇 개의 관심사가 나열된 것뿐이다.
inline-flexitems-centerjustify-centergap-2px-4py-2h-9text-smfont-mediumwhitespace-nowraprounded-mdbg-primarytext-primary-foregroundhover:bg-primary/90focus-visible:ring-2disabled:opacity-50이 순서로 읽는 습관을 들이면 100자짜리 클래스 문자열도 3초 만에 파악된다. 직접 컴포넌트를 만들 때도 이 순서로 쓴다 — 팀이 같은 순서를 쓰면 diff가 읽힌다.
variant와 size는 서로 모른다. 그래서 6 × 4 = 24가지 조합이 자동으로 나온다.
직접 눌러 보면 클래스 문자열이 어떻게 합쳐지는지 보인다 —
cn이 왜 필요한가cn은 두 라이브러리를 합친 것이다.
import { clsx, type ClassValue } from 'clsx'import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs))}없으면 무슨 일이 생기는지 보자.
<Button className="p-8">넓은 버튼</Button><!-- 결과 --><button class="… px-4 py-2 … p-8">px-4 py-2와 p-8이 둘 다 살아 있다.
어느 쪽이 이길지는 CSS 파일에 어떤 순서로 들어갔느냐에 달렸다 — 예측할 수 없다.
<Button className="p-8">넓은 버튼</Button><!-- 결과 --><button class="… p-8">tailwind-merge가 같은 속성을 건드리는 클래스를 알아보고 뒤엣것만 남긴다.
“나중에 준 게 이긴다”는 예측 가능한 규칙이 생긴다.
버튼 하나에 상태가 여럿 있다. 각 상태를 어떤 토큰으로 표현할지가 테마의 일부다.
| 상태 | 클래스 | 규칙 |
|---|---|---|
| 기본 | — | |
| 마우스 올림 | hover: | 보통 accent 또는 원래 색의 90% 투명도 |
| 키보드 포커스 | focus-visible: | ring-ring. 지우면 안 된다 |
| 비활성 | disabled: | opacity-50 + pointer-events-none |
| 오류 | aria-invalid: | border-destructive |
focus-visible과 focus의 차이를 알아두면 좋다.
focus는 마우스 클릭에도 반응해서 클릭할 때마다 테두리가 생긴다.
focus-visible은 키보드로 이동했을 때만 반응한다 — 그래서 이쪽을 쓴다.
70개가 넘지만, 실제로 계속 쓰는 건 이 정도다.
거의 매일
Button · Input · Label · Card ·
Dialog · Select · Table · Badge
자주
Form · Checkbox · Switch · Textarea ·
Tabs · Dropdown Menu · Tooltip · Alert
필요할 때 찾으면 되는 것
Command(⌘K 검색) · Sheet(모바일 서랍) ·
Popover · Skeleton · Sonner(토스트) · Accordion
이런 것도 있다
Data Table · Chart · Carousel · Resizable ·
Input OTP · Sidebar. 이름만 기억.
Form은 조금 특별하다Form은 혼자 동작하지 않는다. react-hook-form + zod와 한 세트로 쓴다.
react-hook-form — 입력값 상태 관리. 리렌더를 최소화한다zod — 검증 규칙을 스키마로 선언. 타입도 같이 나온다Form — 둘을 이어 주고 오류 메시지와 라벨을 접근성 규칙에 맞게 연결한다세 번째가 핵심이다. aria-invalid, aria-describedby를 손으로 붙이면 거의 항상 빠뜨린다.
받은 파일은 내 코드지만, 아무렇게나 고치면 11장의 업스트림 추적이 어려워진다. 세 단계로 나눠 생각한다.
| 하고 싶은 것 | 어디를 | 왜 |
|---|---|---|
| “버튼을 더 각지게” | 토큰 (--radius) | 모든 컴포넌트가 같이 움직인다 |
| “브랜드 색 변경” | 토큰 (--primary) | 한 줄 |
“success variant 추가” | ui/button.tsx | 여기 말고 둘 곳이 없다 |
| “결제 버튼은 항상 로딩 스피너” | 감싸는 컴포넌트 | 도메인 로직은 ui/에 안 넣는다 |
// components/pay-button.tsx ← ui/ 밖import { Button } from '@/components/ui/button'
export function PayButton({ loading, ...props }) { return ( <Button disabled={loading} {...props}> {loading && <Spinner className="size-4" />} 결제하기 </Button> )}button.tsx에 원시 색이 한 번도 안 나온다. 이것이 테마 교체를 가능하게 한다cva는 variant(성격) × size(크기) 두 축을 독립적으로 조합한다. 축을 합치면 폭발한다cn은 클래스 충돌을 해결한다. className을 받는 컴포넌트는 항상 통과시킨다focus가 아니라 focus-visible — 키보드일 때만 반응한다ui/ 파일 → 감싸기 순으로 검토하고, ui/ 수정에는 주석으로 이유를 남긴다