처음 해보는 shadcn/ui

이언덕·2025년 9월 30일
post-thumbnail

💡 shadcn/ui란 무엇일까?

웹사이트를 만들 때는 글자, 버튼, 입력창, 메뉴 같은 UI(사용자 인터페이스) 요소들이 필요하다.
예를 들어, 로그인 페이지를 만든다고 하면 “아이디 입력창 + 비밀번호 입력창 + 로그인 버튼” 같은 기본 UI가 꼭 들어가야 한다.


보통 개발자들은 이런 UI를 매번 처음부터 만들지 않고, 이미 만들어져 있는 컴포넌트(부품)를 가져다 쓴다. 이런 걸 모아둔 게 바로 UI 라이브러리다.


shadcn/ui는 그중 하나인데, 조금 특별하다.
다른 UI 라이브러리는 보통 설치만 하면 바로 쓸 수 있게 제공되는데, shadcn/ui“코드를 직접 프로젝트 안에 복사해 쓰는 방식”을 쓴다.

이게 무슨 뜻일까?

  • 그냥 가져다 쓰는 게 아니라 → 내 코드 안으로 들어온다.
  • 그래서 필요에 따라 내 마음대로 수정할 수 있다.
  • 예를 들어 버튼 색, 크기, 애니메이션을 바꾸는 게 훨씬 자유롭다.

👉 정리하면, shadcn/ui버튼, 모달, 드롭다운 같은 UI를 빠르게 가져오고, 내 입맛대로 고쳐 쓸 수 있는 “UI 부품 상자”라고 생각하면 된다.
참고자료



🤔 왜 shadcn/ui를 써야 할까?

UI 라이브러리는 이미 많다. 그런데 왜 굳이 shadcn/ui를 써야 할까?
핵심은 자유도와 일관성에 있다.


일반적인 UI 라이브러리는 “정해진 디자인”에 맞춰야 해서, 내가 원하는 대로 수정하려면 제약이 많다.
하지만 shadcn/ui는 코드를 직접 들고 오기 때문에 내 프로젝트에 맞게 커스터마이징하기가 훨씬 쉽다.


또한, shadcn/ui는 단순히 코드만 모아둔 게 아니라 Tailwind CSS 디자인 시스템을 기반으로 작성되어 있다.
즉, 가져온 순간부터 내 프로젝트 스타일과 잘 어울리고, 통일성 있는 UI를 빠르게 만들 수 있다.


정리하면, shadcn/ui의 장점은 이렇다:

  • 내 코드처럼 수정 가능 → 자유로운 커스터마이징
  • Tailwind 기반 → 스타일 일관성 확보
  • 빠른 개발 속도 → 자주 쓰는 UI를 바로 가져다 쓸 수 있음

👉 그래서 shadcn/ui“빠르고, 자유롭고, 일관된 UI 개발”을 하고 싶은 사람에게 딱 맞는 선택이다.



⚠️ 설치하기 전에 알아두면 좋은 점

  • 설치가 아니라 “가져오기”
    shadcn/ui는 라이브러리를 링크해 쓰는 게 아니라, 컴포넌트 소스를 내 프로젝트에 복사해 쓴다.

  • 업데이트는 직접 관리
    자동 업그레이드가 없다. 바뀐 템플릿이 필요하면 shadcn add …새 소스를 받아 수동 머지한다.

  • Tailwind 필수
    스타일이 Tailwind 기반이다. v4(config-less)든 v3(설정 파일)든 전역 CSS 셋업이 먼저다.

  • Next.js 친화적
    문서/템플릿이 Next.js(App Router) 기준. 다른 환경도 가능하지만 Next.js가 제일 편하다.

➕ shadcn에서 버튼 커스텀화 — “기본 코드”와 “여기에 추가됨”만으로 끝내기

핵심: src/components/ui/button.tsx 안의 buttonVariants(cva) 를 확장해 variant/size/state를 정의하고,
사용처에서는 variant, size, data-attr(예: selected)로 제어한다.
중요: pill형식(스타일) 예시일 뿐이고, 이름은 완전 자유다. 팀 컨벤션에 맞는 작명을 쓰면 된다.

1) 기본 코드

// src/components/ui/button.tsx
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",
    "rounded-md text-sm font-medium transition-colors",
    "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
    "disabled:pointer-events-none disabled:opacity-50",
    "[&_svg:not([class*='size-'])]:size-4",
  ].join(" "),
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:opacity-90",
        outline: "border bg-transparent hover:bg-muted",
        ghost: "bg-transparent hover:bg-muted",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4",
        sm: "h-8 px-3 text-xs",
        lg: "h-11 px-6 text-base",
        icon: "size-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
  const Comp = asChild ? Slot : "button";
  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}

2) 버튼 커스텀화 (variant/size/state)

2-1) 새로운 스타일(variant)
예시 이름일 뿐이므로, pill, tile, cta, chip, brand원하는 이름으로 다 가능하다.

// buttonVariants 내부 variants.variant 에 "여기에 추가됨"
variant: {
  // ...기존...
  pill: "rounded-full bg-primary/90 text-white hover:bg-primary",
  pillChoice:
    "rounded-full bg-muted text-foreground hover:bg-muted/80 " +
    "data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground",
  tile: "rounded-xl border bg-card text-card-foreground hover:shadow",
},

2-2) 새로운 크기(size)

// buttonVariants 내부 variants.size 에 "여기에 추가됨"
size: {
  // ...기존...
  pill: "h-9 px-5 text-sm",
},

2-3) 선택/토글 상태 키(selected)

// buttonVariants 내부 variants & defaultVariants 에 "여기에 추가됨"
variants: {
  // ...기존 variant, size...
  selected: { true: "", false: "" },
},
defaultVariants: {
  // ...기존...
  selected: false,
},

2-4) 컴포넌트 Prop & data-attr 연결

// 타입 확장 — "여기에 추가됨"
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  selected?: boolean; // 여기에 추가됨
}

// 렌더 연결 — "여기에 추가됨"
export function Button({ className, variant, size, asChild, selected, ...props }: ButtonProps) {
  const Comp = asChild ? Slot : "button";
  return (
    <Comp
      data-slot="button"
      data-selected={selected} // 여기에 추가됨
      className={cn(buttonVariants({ variant, size, selected }), className)} // 여기에 추가됨
      {...props}
    />
  );
}

2-5) 사용 예시

import { Button } from "@/components/ui/button";

export default function Demo() {
  return (
    <div className="flex gap-2">
      <Button variant="tile" size="lg">타일</Button>
      <Button variant="pillChoice" size="pill" selected>오늘</Button>
      <Button variant="pillChoice" size="pill">이번 주</Button>
      <Button variant="pill" size="pill">저장</Button>
    </div>
  );
}

운영 팁

  • shadcn add button 재실행 시 로컬 수정 덮어쓰기 주의 → 먼저 커밋하고 diff 머지.
  • 반복되는 페이지 커스텀은 즉시 variant/size로 승격해 일관성 유지.
  • 네이밍은 자유: pill 같은 예시 대신 팀 컨벤션(역할/톤/도메인)에 맞춰 작명한다.
  • 사이트 전체 톤 변경은 전역 색 토큰(globals.css / @theme) 조정으로 해결.


⚙️ 4) 프로젝트에 shadcn/ui 설치하기

🧩 어떤 프로젝트 환경에서?

  • 권장: Next.js(앱 라우터) + React 18+ + Tailwind CSS
  • 런타임: Node 18+
  • 패키지 매니저: pnpm(권장) / npm / yarn
  • 비고: Tailwind CSS가 없어도 초기화 과정에서 설정을 도와준다. (미리 깔려있어도 됨)
    shadcn/uiNext.js + TypeScript 프로젝트 설치 이후에 바로 세팅하는게 제일 좋은 순서이다.



⬇️ 설치 명령어

pnpm dlx shadcn@latest init
  • 한 번 실행해서 기본 설정을 만든 뒤, 필요한 컴포넌트를 골라서 추가하는 방식이다.
    • 참고: 이 단계에서는 대부분 설정만 바뀐다. components/ 폴더는 보통 아직 없다.



❓ 설치 명령어 실행 시 나오는 질문 (KO/EN)

프로젝트 상태에 따라 일부 항목은 자동 감지되어 생략되거나 경로 질문이 추가될 수 있다.
1. 어떤 스타일을 쓸까요? / Which style would you like to use?

  • 예: Default, New York(디자인 프리셋)

  1. 기본 색상을 무엇으로 할까요? / Which color would you like to use as the base color?
    • 예: slate, gray, zinc, neutral, stone

  2. 색상용 CSS 변수를 설정할까요? / Configure CSS variables for colors?
    • 라이트/다크 모드 토큰을 전역에 추가

  3. 전역 CSS 경로는 어디인가요? (덮어씁니다)
    / Where is your global CSS file? (this file will be overwritten)
    • 예: src/app/globals.css 또는 styles/globals.css

  4. 컴포넌트를 어디에 설치할까요? / Where should components be installed?
    • 예: components → 내부에 components/ui 생성

  5. 유틸 파일 경로는 어디로 할까요? / Where should the utils file be located?
    • 예: lib/utils.ts (cn 등 유틸)

  6. 임포트 별칭을 설정할까요? / Configure the import alias?
    • 예: @/*, @/components, @/lib

  7. 필요한 의존성을 지금 설치할까요? / Install and configure required dependencies now?
    • Tailwind/애니메이션/유틸 패키지 설치



📦 설치가 끝나면: 생성·수정되는 것

✅ 폴더

  • components/
    • components/ui/ : 컴포넌트 소스가 복사될 위치

  • lib/
    • lib/utils.ts : cn 등 유틸 함수

✅ 파일

  • components.json
    • baseColor, 전역 css 경로, aliases초기화 답변이 기록됨

  • tailwind.config.* (갱신 또는 생성 V4버전일 경우 tailwind.config.*이 없을 수도 있음)
    • 프리셋/플러그인/테마 설정 반영

  • 전역 CSS (예: src/app/globals.css)
    • 라이트/다크 CSS 변수 토큰 주입

  • tsconfig.json / jsconfig.json
    • @/*임포트 별칭 반영

  • package.json
    • 의존성 추가/갱신

✅ 코드(내용)

  • 전역 CSS에 컬러 토큰(:root, .dark)과 기본 스타일 스니펫 추가
  • tailwind.config에 애니메이션/프리셋/경로 등 반영
  • 별칭 기반 임포트 사용 가능: @/components/ui/..., @/lib/utils



✅ 설치가 끝나면: 생성·수정되는 것 (정리)

항상 생성/수정(= init 결과)

  • components.json: baseColor, 전역 CSS 경로, 별칭 등 질문 답변이 기록된다.
  • tailwind.config.* 업데이트: 프리셋/플러그인/경로 반영.
  • 전역 CSS(예: src/app/globals.css) 갱신: 라이트/다크 CSS 변수 토큰 주입.
  • lib/utils.ts: cn 등 유틸 생성(또는 갱신).
  • tsconfig.json/jsconfig.json: @/*경로 별칭 반영.
  • package.json: 필요한 의존성 추가/갱신.

중요: 이 시점엔 components/ 폴더가 없을 수 있다. 정상이다.

첫 컴포넌트를 추가할 때 생성(= add 결과)

  • components/components/ui/…: 실제 컴포넌트 소스 파일이 여기 생긴다.
  • (필요 시) Radix/아이콘 등 추가 의존성이 함께 설치된다.



🧪 간단 테스트 (동작 확인)

  1. 컴포넌트 하나 추가
pnpm dlx shadcn@latest add button

.


2. 페이지에서 사용

// 예: src/app/page.tsx
import { Button } from "@/components/ui/button";
---
export default function Page() {
  return <Button variant="outline">확인</Button>;
}
  1. 로컬 실행
pnpm dev
  • 브라우저에서 페이지를 열었을 때 Button이 정상 렌더링되면 성공이다.


🏗️ 5) 초기 설정과 기본 구조 이해하기

🧾 components.json 핵심 필드

init에서 답한 값이 기록되고, 이후 add로 가져오는 컴포넌트의 규칙이 된다.

{
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  },
  "tailwind": {
    "css": "src/app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  }
}
  • aliases.components: 컴포넌트 임포트 기준 경로 (@/components → 실제 src/components)
  • aliases.utils: 유틸 경로 (@/lib/utils)
  • tailwind.css: 전역 CSS 경로
  • tailwind.baseColor: 초기 팔레트 베이스 색
  • tailwind.cssVariables: 라이트/다크 컬러 토큰 활성화



📂 폴더/파일 트리 (상태별)

init 직후

project/
├─ src/
│  └─ app/
│     └─ globals.css          # 전역 CSS(컬러 토큰 주입)
├─ lib/
│  └─ utils.ts                # cn 유틸
├─ components.json            # 초기화 답변 기록
├─ tailwind.config.(js|ts)?   # v3/설정 파일 방식이면 존재, v4 config-less면 없을 수 있음
├─ tsconfig.json              # @/* 등 별칭
└─ package.json               # 의존성

.

첫 컴포넌트 add

project/
└─ src/
   ├─ app/...
   ├─ lib/utils.ts
   └─ components/
      └─ ui/
         └─ button.tsx        # 예: add button 시 생성

components/ui/*add 시점에 생긴다. init만으로는 안 생겨도 정상이다.



🎨 Tailwind 설정에 따른 전역 CSS (두 가지 케이스)

0) 개념 정리

Tailwind와 shadcn/ui를 함께 쓰다 보면 전역에 여러 색 토큰이 생긴다.
이걸 이해하려면 두 개의 개념만 기억하면 된다.

  • 원시 팔레트 (Primitive)
    → 피그마나 디자인 시스템에서 정한 “색 그 자체”
    예: --brand-500, --gray-100, --blue-400

  • 의미 토큰 (Semantic)
    → 컴포넌트가 쓰는 “역할 이름”
    예: --primary, --background, --border

보통은 원시 팔레트를 유지하면서 의미 토큰을 거기에 매핑한다.
즉, 색의 원본은 그대로 두고, 각 컴포넌트가 이해할 수 있는 이름만 붙이는 방식이다.



1) shadcn이 전역에 “추가하는 것”(정확히)

🧩 코어 (필수)

대부분의 컴포넌트가 참조하는 핵심 색상이다.

--background / --foreground  
--primary / --primary-foreground  
--muted / --muted-foreground  
--border, --input, --ring  
--destructive / --destructive-foreground

🎨 편의 (선택)

일부 패턴에서만 쓰이는 보조 색상이다.

--card / --card-foreground  
--popover / --popover-foreground  
--secondary / --secondary-foreground  
--accent / --accent-foreground

⚙️ Tailwind 유틸 매핑

  • v3 (설정파일 방식)theme.extend.colors.{background, primary, …} = hsl(var(--...))
  • v4 (config-less)@theme inline { --color-background: var(--background); … }


    정리 요약
    코어 토큰은 꼭 유지하고, 편의 토큰은 필요할 때만 추가하면 된다.
    (토큰 정의 + 매핑 둘 다 생략 가능)



2) 일반적으로는 이렇게 동작한다

보통 shadcn init을 실행하면 globals.css
기본 팔레트가 자동 추가되고, 그 색을 shadcn의 토큰으로 주입하는 구조가 만들어진다.

:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
  --primary: 221 83% 53%;
  --muted: 210 40% 96%;
  --border: 214 32% 92%;
  /* … 다른 토큰들 */
}

@theme inline {
  --color-background: var(--background);
  --color-primary: var(--primary);
  /* … */
}

즉,
“shadcn이 기본 색 구조를 만들어두고,
Tailwind가 그걸 유틸(bg-background, text-foreground)로 변환해 쓰는”
전형적인 패턴이다.

✅ 케이스 A — tailwind.config.(js|ts) 있음 (v3 스타일 또는 v4에서도 설정 파일 사용하는 경우)

tailwind.config.ts(예시)

import type { Config } from "tailwindcss";
.
export default {
  content: [
    "./src/app/**/*.{ts,tsx}",
    "./src/components/**/*.{ts,tsx}",
    "./src/lib/**/*.{ts,tsx}"
  ],
  theme: {
    extend: {
      colors: {
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: "hsl(var(--primary))",
        muted: "hsl(var(--muted))",
        border: "hsl(var(--border))"
      }
    }
  },
  plugins: [require("tailwindcss-animate")]
} satisfies Config;

globals.css(예시)

@tailwind base;
@tailwind components;
@tailwind utilities;
.
/* 1) 디자인 토큰 */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --muted: 210 40% 96.1%;
    --border: 214.3 31.8% 91.4%;
  }
  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --muted: 217.2 32.6% 17.5%;
    --border: 217.2 32.6% 17.5%;
  }
}
.
/* 2) 기본 바탕 적용 */
@layer base {
  * { @apply border-border; }
  body { @apply bg-background text-foreground; }
}

이 방식은 색 이름(bg-background, text-foreground)을 config의 theme.extend.colors에서 hsl(var(--...))로 매핑해 쓴다.

🅱️ 케이스 B - Tailwind v4 Config-less 방식

shadcn이 추가해준 기본 팔레트를 그대로 쓸 필요는 없다.
프로젝트 브랜드 팔레트가 이미 명확하다면,
globals.css 안의 @theme inline직접 커스텀 토큰을 정의할 수도 있다.


즉,
“원시 팔레트 + 의미 토큰”으로 분리하지 않고,
한 번에 inline에서 색을 정의하는 구조다.
(config 파일도 필요 없다)

✅ 예시 — globals.css 한 곳에서 커스텀 토큰 정의하기

/* 0) Tailwind 활성화 */
@import "tailwindcss";

/* 1) 커스텀 토큰을 직접 정의 (inline에서 바로 의미 토큰화) */
@theme inline {
  /* radius scale */
  --radius-sm: 0.4rem;
  --radius-md: 0.6rem;
  --radius-lg: 0.8rem;
  --radius-xl: 1rem;
  --radius: var(--radius-md);

  /* core palette (브랜드 색 직접 입력) */
  --background: #fafafa;
  --foreground: #111827;

  --primary: #06c;
  --primary-foreground: #ffffff;

  --muted: #f5f5f5;
  --muted-foreground: #525252;

  --border: #e5e5e5;
  --input: #d4d4d4;
  --ring: #60a5fa;

  --destructive: #dc2626;
  --destructive-foreground: #ffffff;

  /* 필요한 편의 토큰만 선택적으로 추가 */
  --card: var(--background);
  --card-foreground: var(--foreground);
}

/* 2) Base Reset */
@layer base {
  :root { color-scheme: light; } /* 다크모드 미사용 */

  html {
    font-family: Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
    font-size: 62.5%;
  }
  body {
    background: var(--background);
    color: var(--foreground);
    line-height: 1.6;
    min-height: 100dvh;
  }
  * { @apply border-border; }
}

/* 3) 팀 공통 타이포그래피 유틸 (선택) */
@layer utilities {
  .t-14-b { font-size: 1.4rem; font-weight: 700; }
  /* … */
}

.

💬 이렇게 구성했을 때의 장점

구분일반적인 shadcn 방식커스텀 inline 방식
팔레트 관리shadcn 기본 팔레트 자동 주입프로젝트 브랜드 색 직접 정의
파일 구조설정파일(config) 또는 별도 팔레트 필요globals.css 한 곳에서 관리
유지보수shadcn 업데이트에 영향 받음내가 정의한 토큰만 관리
다크모드기본 포함미사용 (Light 고정 가능)
확장성토큰이 많고 복잡필수 토큰만 정의해 단순함

💡 정리하자면

대부분의 프로젝트는 shadcn이 만들어준 팔레트를 그대로 사용하지만,
Tailwind v4 환경에서는 globals.css 한 곳에서 직접 커스텀 토큰을 정의하는 방식도 충분히 가능하다.

이 방법은

  • 의미 없는 기본 토큰을 제거하고,
  • 프로젝트의 실제 색상을 한눈에 볼 수 있으며,
  • 유지보수가 훨씬 단순해진다는 장점이 있다.

즉, “shadcn의 구조를 그대로 쓰기보다 내 디자인 시스템을 중심으로 재정의한다”는 관점이다.



🧰 lib/utils.tscn 유틸

조건부 클래스를 합칠 때 중복을 깔끔하게 정리한다.

// lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
.
export function cn(...inputs: unknown[]) {
  return twMerge(clsx(inputs));
}

사용 예시

import { cn } from "@/lib/utils";
.
export function Chip({ active }: { active?: boolean }) {
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-md px-2 py-1 text-sm",
        active ? "bg-primary text-white" : "bg-muted text-foreground/70"
      )}

      Label
    </span>
  );
}

.



🔗 경로 별칭 동작 확인

  • tsconfig.json에서 @/* → src/* 매핑을 맞춘다.
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  }
}
  • components.jsonaliases.components"@/components"라면 실제 생성 위치는 src/components다.



🌓 (선택) 테마 Provider 배선

다크 모드를 쓸 계획이면 루트 레이아웃에서 한 번만 감싼다.

// src/app/layout.tsx
import { ThemeProvider } from "next-themes";
import "./globals.css";
.
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

👉 한 줄 요약: init은 규칙과 토큰을 깐다.
Tailwind는 “설정 파일 방식”과 “v4 config-less” 두 갈래로 전역 CSS가 달라진다.
이후 add가 실제 컴포넌트를 복사한다.



🧩 6) 컴포넌트 추가하고 사용하는 방법

1) 필요한 컴포넌트 고르기 → 추가

# 예: 버튼만
pnpm dlx shadcn@latest add button
--
# 예: 버튼 + 입력 + 다이얼로그
pnpm dlx shadcn@latest add button input dialog
  • 템플릿 소스가 components/ui/*에 복사된다.
  • 필요한 의존성(예: @radix-ui/react-*, class-variance-authority, tailwind-merge, clsx)이 없는 경우에만 자동 추가된다.
  • 동일 파일이 있으면 덮어쓸지 확인한다.



2) 생성 위치 빠른 체크

src/
├─ components/
│  └─ ui/
│     ├─ button.tsx
│     ├─ input.tsx
│     └─ dialog.tsx   # 예시
└─ lib/utils.ts

components.jsonaliases.components"@/components"이면 실제 폴더는 보통 src/components다.
보이지 않으면 tsconfig.json@/* → src/* 매핑과 components.json의 별칭을 맞춘다.



3) 바로 써보기

A. Button

// src/app/page.tsx
import { Button } from "@/components/ui/button";
.
export default function Page() {
  return (
    <div className="p-6 space-x-2">
      <Button>기본</Button>
      <Button variant="outline">아웃라인</Button>
      <Button size="sm">작게</Button>
    </div>
  );
}

B. Input + label

import { Input } from "@/components/ui/input";
.
export default function FormMini() {
  return (
    <form className="p-6 space-y-2">
      <label htmlFor="email" className="text-sm font-medium">이메일</label>
      <Input id="email" type="email" placeholder="you@example.com" />
    </form>
  );
}

C. Dialog

"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription
} from "@/components/ui/dialog";
.
export default function DemoDialog() {
  const [open, setOpen] = useState(false);
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">열기</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>알림</DialogTitle>
          <DialogDescription>다이얼로그가 열렸다.</DialogDescription>
        </DialogHeader>
      </DialogContent>
    </Dialog>
  );
}

상호작용이나 상태가 있는 파일은 상단에 use client를 선언한다.



4) UI 커스터마이징

className으로 국소 덮어쓰기

대부분 컴포넌트는 내부에서 cn(내 클래스, className) 순으로 병합한다.
겹치면 내가 넘긴 className이 우선한다. 색·크기·여백처럼 미세 조정에 적합하다.

<Button className="h-11 px-6 bg-emerald-600 hover:bg-emerald-700">
  저장
</Button>
.
<DialogContent className="max-w-lg rounded-2xl p-8">...</DialogContent>

더 큰 톤 변경이 필요하면 전역 HSL 토큰을, 재사용 표준화가 필요하면 cvavariant·size를 고려하면 된다.



5) 접근성/UX 포인트

  • labelinput을 연결한다. 스크린리더가 필드명을 읽고 라벨 클릭으로 포커스가 이동한다.
<label htmlFor="email" className="text-sm font-medium">이메일</label>
<Input id="email" type="email" placeholder="you@example.com" />

오류 문구가 있으면 aria-describedby="email-error"로 연결한다.

  • Dialog/Sheet/PopoverEsc로 닫힘과 포커스 트랩이 동작한다. 제목/설명은 DialogTitle, DialogDescription으로 전달한다.
<Dialog>
  <DialogTrigger asChild><Button>열기</Button></DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>알림</DialogTitle>
      <DialogDescription>설명을 여기에.</DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>

.

  • 포커스 링은 유지한다. 제거했다면 대체 포커스 스타일을 제공한다.
<Button className="focus-visible:outline-none
                   focus-visible:ring-2 focus-visible:ring-primary
                   focus-visible:ring-offset-2 ring-offset-background">
  저장
</Button>

.



6) 업데이트/삭제 — 왜, 언제, 어떻게

복사된 템플릿 코드는 자동으로 갱신되지 않는다.
버그·접근성 개선이나 Tailwind/Next 변화에 맞추려면 업데이트가 필요하다.
쓰지 않는 컴포넌트·패키지를 정리하면 번들 크기와 유지보수 부담이 줄어든다.

언제

메이저 업그레이드 전후, 접근성/렌더 이슈 발견 시, 전역 토큰 변경 후 일부 톤이 어긋날 때, 템플릿 신규 구조를 통일하고 싶을 때가 적기다.

어떻게

업데이트(재-add)

# 변경사항 커밋 후
pnpm dlx shadcn@latest add button
# 다르면 덮어쓸지 묻는다. 커스터마이징이 크면 임시 파일로 받아 수동 머지한다.

.


삭제
1. components/ui/x.tsx를 제거한다.
2. 전역의 import와 JSX 사용처를 정리한다.
3. 필요하면 의존성도 정리한다.

pnpm why @radix-ui/react-dialog
pnpm remove @radix-ui/react-dialog

.



7) 흔한 오류 빠른 진단

  • 경로 에러: @/* → src/*(tsconfig.json)와 components.json의 별칭이 실제 경로와 일치하는지 확인한다.

  • 스타일 미적용:
    • v4(config-less): 전역 CSS에 @import "tailwindcss"@theme가 있는지 확인한다.
    • v3/설정 파일: @tailwind base/components/utilitiestheme.extend.colors 매핑을 확인한다.

  • 상호작용 미동작: use client 선언 누락을 확인한다.

  • 의존성 누락: add를 재실행하거나 package.json 변경을 확인한다.


🚀 프로젝트에 적용하기 (세팅만)

✅ 1) 준비 점검: Next.js / Tailwind / 경로 별칭

1-1. 런타임·패키지 버전

node -v        # >= 18
pnpm -v
jq -r '.dependencies.next,.dependencies.react,.devDependencies.typescript' package.json
  • 권장: next@14+, react@18+, typescript 사용

1-2. App Router 구조

src/
└─ app/
   ├─ layout.tsx
   └─ page.tsx

layout.tsx에서 전역 CSS를 불러온다:

// src/app/layout.tsx
import "./globals.css";

.

1-3. Tailwind 모드 결정 + 전역 CSS 경로 확정 (중요)

전역 CSS는 src/app/globals.css 로 통일한다. 파일이 없다면 지금 만든다.
아래 두 모드 중 하나를 택해 내용만 넣어준다.

A) v4 config-less (설정 파일 없음)

  • 루트에 tailwind.config.* 없어도 된다.
  • 전역 CSS는 한 줄이면 된다:
/* src/app/globals.css */
@import "tailwindcss";
  • 토큰/테마는 5단계에서 @theme 등으로 추가한다.

B) 설정 파일 방식 (v3 또는 v4-with-config)

  • 루트에 tailwind.config.(js|ts) 존재.
  • 전역 CSS는 지시문을 사용:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

.

1-4. 경로 별칭 정렬 (@/* → src/*)

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  }
}

이후 shadcncomponents.json에서 "aliases.components": "@/components"로 잡으면 실제 물리 경로는 src/components가 된다.

1-5. 빠른 시동 점검

pnpm dev
# 에러 없이 서버가 뜨는지 확인

.



⚙️ 2) shadcn init 실행

2-1. 명령 실행

pnpm dlx shadcn@latest init
  • 초기 세팅만 잡는 단계다. 이 시점엔 components/ui/* 폴더가 없어도 정상이다(다음 단계 add에서 생성).

2-2. “왜 Base color만 떴지?”

shadcn CLI가 프로젝트 상태를 자동 감지해서 비어 있는 항목만 질문한다.
Next.js + TS처음부터 세팅 직후엔 질문이 여러 개 나오지만, 중간 도입이면 이미 있는 설정을 스킵해 “Base color만” 묻는 경우가 흔하다.

2-3. 이번 시나리오(질문: Base color만) — 선택값 & 수정 포인트

  • 내가 고른 Base color: zinc

향후 바꾸고 싶다면 components.json에서 수정할 수 있다

{
  "aliases": { "components": "@/components", "utils": "@/lib/utils" },
  "tailwind": {
    "css": "src/app/globals.css",
    "baseColor": "zinc",      // ← 여기 값을 바꾸면 됨 (예: "slate")
    "cssVariables": true
  }
}
  • 참고: baseColor를 바꿔도 이미 주입된 globals.css의 토큰 값은 자동 변경되지 않는다.
    전역 색 토큰을 함께 바꾸려면 globals.css의 HSL 변수(5단계에서 다룸)를 수동 조정하거나, 필요 시 템플릿을 다시 들여와 반영하는 식으로 맞춘다.



🗂️ 3) 생성물 확인

3-1. 현재 디렉터리 상태(요약)

project/
├─ src/
│  ├─ app/
│  │  └─ globals.css        # 전역 CSS (v4 config-less)
│  └─ lib/
│     └─ utils.ts           # cn 유틸
├─ components.json          # shadcn 설정
└─ (없음) src/components/ui  # ← 컴포넌트 소스는 아직 없음 (다음 단계 add)

.

3-2. components.json

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/app/globals.css",
    "baseColor": "zinc",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "registries": {}
}

핵심 포인트

  • "style": "new-york": 프리셋(스타일 톤).
  • "tailwind.baseColor": "zinc": 베이스 컬러. 필요하면 여기서 값만 바꿔도 됨.
  • "tailwind.config": "": v4 config-less 모드(설정 파일 없음).
  • "tailwind.css": "src/app/globals.css": 전역 CSS 경로.
  • "aliases": @/components, @/lib, @/components/ui 등을 바로 임포트할 수 있음.
  • "iconLibrary": "lucide": 아이콘 기본 라이브러리.

3-3. lib/utils.ts (cn 유틸)

// src/lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
.
export function cn(...inputs: unknown[]) {
  return twMerge(clsx(inputs));
}
  • className 병합/중복 해소에 사용한다.

3-4. globals.css — 왜 많았고, 뭐가 추가된 거가요? 그리고 무엇을 남겼나

왜 많았나

  • shadcn init는 바로 써볼 수 있게 테마 토큰(라이트/다크), 베이스 스타일, Tailwind v4용 @theme 매핑을 한꺼번에 넣는다.

  • v4에서는 @import "tailwindcss" + @theme/@theme inline만으로 bg-background, text-foreground 같은 유틸 클래스가 생성되도록 설계되어 있다.

이번에 실제로 어떤 것들이 추가/변경됐나 (핵심)

아래 내용은 기본적으로 shadcn init 직후 globals.css에 반영되는 전형적인 항목들이다.
(프로젝트/버전에 따라 표현이 조금 달라질 수 있음. 그래도 “무엇을, 왜 넣는지”는 같음)
1. Tailwind v4 활성화

  • @import "tailwindcss"; 한 줄이 들어간다.
    → Tailwind 유틸리티들이 빌드에 포함되도록 엔진을 켠다.

  1. 색 토큰(라이트/다크) 주입
  • 라이트 모드 기본값: :root { --background, --foreground, --primary, … }가 추가된다.
  • 다크 모드 덮어쓰기: .dark { --background, --foreground, --primary, … }가 함께 들어간다.
    → 컴포넌트들이 공통적으로 참조할 의미 토큰(semantic) 세트를 만들어 준다.

  1. Tailwind 유틸 매핑용 @theme inline
  • @theme inline { --color-background: var(--background); … }가 들어간다.
    bg-background, text-foreground, border-border, ring 같은 유틸 클래스가 생성되도록
    의미 토큰 → Tailwind 색 이름표(--color-*)로 연결한다.

  1. 베이스 레이어 기본값
  • @layer base 블록에서 다음이 설정된다
    • body { background: var(--background); color: var(--foreground); }
    • 포커스 링: :focus-visible { outline: 2px solid var(--ring); … }
    • 텍스트 선택 색: ::selection { background: var(--primary); color: var(--primary-foreground); }
    • (종종) 전역 보더 색: * { @apply border-border; }
    • (종종) 폰트 스무딩 / 숫자표기 / 모션 감소 등 접근성 기본값

  1. 반경/애니메이션 등 공통 토큰
  • --radius 계열(예: --radius-sm/md/lg)과 tailwindcss-animate 기반 애니메이션 프리셋이 포함되기도 한다.
    → shadcn 컴포넌트의 모서리/포커스/모션 일관성을 위한 공통 설정.

  1. (가끔) 데모/예시용 토큰
  • 템플릿 미리보기를 위해 차트/사이드바 같은 예시 토큰이 따라오는 경우가 있다.
    → 실제 프로젝트에선 불필요하면 삭제해도 무방하다.

기본 주입 예시(요약)

@import "tailwindcss";

/* 라이트/다크 의미 토큰 */
:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
  --primary: 221 83% 53%;
  --muted: 210 40% 96%;
  --border: 214 32% 92%;
  --ring: 217 100% 50%;
  /* ... */
}
.dark {
  --background: 222 84% 5%;
  --foreground: 210 40% 98%;
  --primary: 217 92% 60%;
  /* ... */
}

/* Tailwind 유틸 매핑 */
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: #fff;
  --color-muted: var(--muted);
  --color-border: var(--border);
  --color-ring: var(--ring);
  /* ... 필요시 card/secondary/accent 등 편의 토큰 */
}

/* 베이스 적용 */
@layer base {
  body { background: var(--color-background); color: var(--color-foreground); }
  :focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
  ::selection { background: var(--color-primary); color: var(--color-primary-foreground); }
  /* ... */
}

이게 “init 직후의 기본 상태”다.
이후 우리는 이 기본값을 우리 프로젝트에 맞게 슬림화/정돈(다크 제거, 불용 토큰 삭제, 의미 토큰 최소 세트 유지, 커스텀 용도 토큰 추가 등)했다.

나는 이렇게 커스텀화했다

  • 색상은 @theme inline의 의미 토큰이 단일 소스가 되도록 정리했다.
    → 팔레트 값은 바로 의미 토큰에 입력(별도의 원시 팔레트/다크 세트 미유지).

  • 필수 코어 토큰만 남기고, 편의 토큰은 전부 제거(필요해질 때만 추가).

  • 우리 문서에 있던 “사용처 주석”을 실제 토큰으로 승격해, 팀이 어디에 무엇을 써야 하는지 이름만 봐도 이해되게 했다.
  • 베이스 레이어는 모두 --color-*로 통일해 Tailwind 유틸(bg-background, text-foreground, border-border, ring)과 일관되게 동작하도록 했다.

  • 다크 모드를 쓰지 않으므로 관련 토큰/스타일은 전부 제거해 복잡도를 낮췄다.

수정된 globla.css (수정 코드만)

/* -------------------------------------------------
   0) Tailwind & 외부 의존성
   ------------------------------------------------- */
@import "tailwindcss";

/* Pretendard: pnpm add @fontsource/pretendard */
@import "@fontsource/pretendard/400.css";
@import "@fontsource/pretendard/500.css";
@import "@fontsource/pretendard/600.css";
@import "@fontsource/pretendard/700.css";

/* 선택: 애니메이션 유틸 */
@import "tw-animate-css";

/* (주의) 다크모드 미사용: dark 변형자/변수 정의 없음 */

/* -------------------------------------------------
   1) Design Tokens — 브랜드/테마 (Light only)
   - @theme: 원본 브랜드/팔레트
   - @theme inline: Tailwind/shadcn이 쓰는 이름으로 매핑
   ------------------------------------------------- */
@theme {
  /* === Font stacks === */
...

  /* === Breakpoints === */
...

  /* === Colors === */
  --color-black: #000000; /* 메인 텍스트 */
  --color-white: #ffffff;

  /* === Brand Colors === */
  --primary-blue: #06c;
  --primary-gray: #111827;
  --primary-red: #dc2626;

  /* === Gray Scale === */
  --color-gray-900: #111827; /* 랜딩/로그인 메인 텍스트, 제목, CTA 텍스트, 로그인버튼 배경, 링크 */
  --color-gray-800: #1f1f1f; /* 모든 버튼 :hover */
  --color-gray-700: #2e2e2e; /* 인풋 라벨 텍스트 */
  --color-gray-600: #525252; /* 보조 텍스트, Nav 텍스트, 설명문, 푸터 텍스트, 안내문구 */
  --color-gray-500: #737373; /* 플레이스홀더, 비활성 텍스트, 간편 로그인 제목 */
  --color-gray-400: #a3a3a3; /* 서브 경계선, 보조 아이콘, 비활성 버튼 배경 */
  --color-gray-300: #d4d4d4; /* 버튼 테두리, 입력 필드 테두리 */
  --color-gray-200: #e5e5e5; /* 카드/모달 테두리, 구분선 */
  --color-gray-100: #f5f5f5; /* 버튼 배경 */
  --color-gray-50: #fafafa; /* 푸터/페이지 기본 배경 */

  /* === Error / Danger Scale === */
  --color-danger-600: #dc2626; /* 에러/경고 상태, 경고 버튼 */

  /* === Blue Scale === */
  --color-blue-600: #2563eb; /* 진한 블루: 모달 헤더, 주요 CTA 강조 */
  --color-blue-500: #3b82f6; /* 기본 블루: 일반 버튼, 포인트 */
  --color-blue-400: #60a5fa; /* 밝은 블루: hover 보조 강조 */
  --color-blue-100: #3b82f61a; /* 연한 블루 배경 (10% 투명도 느낌) */

  /* === Radius & Shadow === */
  --radius-2xl: 1rem;
  --shadow-soft: 0 6px 16px rgba(0, 0, 0, 0.08);
}

/* === Tailwind/shadcn 매핑 (Light) ============================
   - bg-background → var(--color-background)
   - text-foreground → var(--color-foreground)
   - border-border → var(--color-border)
   - ring, input, muted 등 컴포넌트 공통 토큰
   - 버튼/플레이스홀더/비활성/링크/호버 등 "주석 용도"에 맞춘 커스텀 토큰 추가
   ============================================================ */
@theme inline {
  /* radius scale (shadcn 컴포넌트 대응) */
  --radius-sm: calc(var(--radius-2xl) - 8px);
  --radius-md: calc(var(--radius-2xl) - 4px);
  --radius-lg: var(--radius-2xl);
  --radius-xl: calc(var(--radius-2xl) + 4px);
  --radius: var(--radius-lg);

  /* === shadcn 코어 토큰 (필수) === */
  --color-background: var(--color-gray-50); /* 페이지/푸터 배경 */
  --color-foreground: var(--color-gray-900); /* 메인 텍스트/제목/CTA 텍스트 */

  --color-primary: var(--primary-blue); /* 브랜드 프라이머리 (CTA, 액션) */
  --color-primary-foreground: #ffffff; /* 프라이머리 위 텍스트 */

  --color-muted: var(--color-gray-100); /* 버튼/보조 영역 배경 */
  --color-muted-foreground: var(--color-gray-600); /* 보조 텍스트/Nav/설명문 */

  --color-border: var(--color-gray-200); /* 카드/모달 테두리, 구분선 */
  --color-input: var(--color-gray-300); /* 입력 필드 테두리 */
  --color-ring: var(--color-blue-400); /* 포커스 링 */

  --color-destructive: var(--color-danger-600); /* 위험/에러 */
  --color-destructive-foreground: #ffffff;

  /* === 주석 기반 "사용처 특화" 커스텀 토큰 === */
  --color-button-solid: var(--color-gray-900); /* 로그인 버튼 배경(고정 다크) */
  --color-button-solid-foreground: #ffffff; /* 로그인 버튼 텍스트 */
  --color-button-hover: var(--color-gray-800); /* 모든 버튼 :hover(다크계열) */

  --color-placeholder: var(--color-gray-500); /* 인풋 플레이스홀더 */
  --color-label: var(--color-gray-700); /* 인풋 라벨 텍스트 */

  --color-disabled-foreground: var(--color-gray-500); /* 비활성 텍스트 */
  --color-disabled-background: var(--color-gray-400); /* 비활성 버튼 배경 */

  --color-subtle-border: var(--color-gray-400); /* 서브 경계선 */
  --color-link: var(--color-gray-900); /* 텍스트 링크 (비밀번호/회원가입) */

  /* === 블루 계열 특화 (선택적으로 사용) === */
  --color-cta: var(--color-blue-600); /* 주요 CTA 강조 */
  --color-cta-foreground: #ffffff;
  --color-button: var(--color-blue-500); /* 일반 버튼 */
  --color-button-foreground: #ffffff;
  --color-hover-accent: var(--color-blue-400); /* 보조 hover 강조 */
  --color-subtle-accent-bg: var(--color-blue-100); /* 연한 블루 배경 */
}

/* -------------------------------------------------
   2) Base Reset & a11y
   ------------------------------------------------- */
@layer base {
  :root {
    color-scheme: light;
  } /* 라이트 고정 */

  html {
    font-size: 62.5%; /* 1rem = 10px */
    font-family: var(--font-sans);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
    font-feature-settings: "liga", "kern";
    font-variant-numeric: tabular-nums;
    font-synthesis-weight: none;
  }
  html:lang(ko) {
    font-family: var(--font-sans-ko);
  }

  body {
    background: var(--color-background);
    color: var(--color-foreground);
    line-height: 1.6;
    letter-spacing: -0.01em;
    min-height: 100dvh;
  }

  /* 페이지 공통 컨테이너 (선택) */
  .container {
    max-width: 1200px;
    margin-inline: auto;
    padding-inline: 1.6rem;
  }

  /* 접근성: 키보드 포커스 링 */
  :focus-visible {
    outline: 2px solid var(--color-ring);
    outline-offset: 2px;
  }
  @media (forced-colors: active) {
    :focus-visible {
      outline: 2px solid CanvasText;
    }
  }

  /* 텍스트 선택 */
  ::selection {
    background: var(--color-primary);
    color: var(--color-primary-foreground);
  }

  /* 과도한 모션 방지 */
  @media (prefers-reduced-motion: reduce) {
    * {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }
}

/* 모든 요소에 기본 border 색/outline 색만 지정 (폭은 건드리지 않음) */
@layer base {
  * {
    @apply border-border;
    outline-color: oklch(from var(--color-ring) l c h / 0.5);
  }
}

/* -------------------------------------------------
   3) 팀 공통 타이포그래피 유틸
   ------------------------------------------------- */
@layer utilities {
/* 폰트 유틸 ... */

.

이렇게 줄여도 add 시 괜찮나?

  • 문제 없음. 런타임 에러는 없고, 카드/팝오버/세컨더리/액센트류도 폴백 덕분에 기본 스타일이 안전하게 나온다.
  • 더 섬세한 톤이 필요해지면 토큰 값만 조정하면 된다(컴포넌트 소스 수정 불필요).


🧩 5) 컴포넌트 추가 → 렌더 & 커스텀 테스트

5-1. 컴포넌트 추가

# 하나씩
pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add input
# 여러 개 한 번에
pnpm dlx shadcn@latest add button input
  • 실행하면 소스가 프로젝트에 복사되고, 필요한 Radix / 유틸 의존성이 자동 추가됨.



5-2. 생성물 확인

src/
├─ components/
│  └─ ui/
│     ├─ button.tsx
│     └─ input.tsx
└─ lib/utils.ts       # cn 유틸(이미 있음)

  • 안 보이면: components.jsonaliases.ui(예: "@/components/ui")와 tsconfig.json@/* → src/* 매핑 확인.

button.tsx — 구조 이해

0) imports

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";
  • Slot: <Button asChild><Link/></Button>처럼 다른 태그에 버튼 스타일 이식
  • cva/VariantProps: variant/size 규칙 선언 + 타입 안전
  • cn: 클래스 병합 유틸

1) cva로 “규칙 세트” 선언

const buttonVariants = cva(
  "inline-flex items-center ... focus-visible:ring-[3px] ...",
  {
    variants: {
      variant: { default: "...", outline: "...", ghost: "...", /* ... */ },
      size: { default: "h-9 px-4", sm: "h-8 px-3", lg: "h-10 px-6", icon: "size-9" }
    },
    defaultVariants: { variant: "default", size: "default" }
  }
);
  • 베이스: 공통 레이아웃/타이포, 아이콘 규칙(&[_svg]), 포커스/에러 a11y 포함
  • variants: 시각 스타일(variant) & 크기(size) 이름으로 선택
  • defaultVariants: 옵션 생략 시 기본값

2) 베이스가 하는 일 (대표 라인만)

"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg:not([class*='size-'])]:size-4",   // 아이콘 기본 16px
"focus-visible:ring-ring/50 focus-visible:ring-[3px]",  // 키보드 포커스
"aria-invalid:border-destructive"          // 에러 상태
  • 접근성/상태/아이콘 처리를 베이스에서 표준화

3) 실제 컴포넌트

function Button({ className, variant, size, asChild=false, ...props }: Props) {
  const Comp = asChild ? Slot : "button";
  return (
    <Comp
      data-slot="button"
      className={cn( buttonVariants({ variant, size }), className )}
      {...props}
    />
  );
}
  • asChild: 다른 태그로 렌더하되 버튼 스타일 유지

  • cn 병합: cva 결과 + 추가 클래스 충돌 없이 결합


    커스텀 포인트(버튼)

  • 팀 공통 스타일 넣기: variants.variant에 키 추가/정리

  • 크기 체계: variants.size 확장

  • 기본 규격 변경: 베이스 문자열 수정



input.tsx — 구조 이해

0) imports

import * as React from "react";
import { cn } from "@/lib/utils";
  • cn: 공통 클래스 병합

1) 베이스(폼 규격) + 상태 클래스

function Input({ className, type, ...props }) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base md:text-sm",
        "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
        "aria-invalid:border-destructive aria-invalid:ring-destructive/20",
        "disabled:pointer-events-none disabled:opacity-50",
        "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
        className
      )}
      {...props}
    />
  );
}
  • 베이스: 크기/라운드/보더/배경/폰트 사이즈

  • placeholder/selection: 플레이스홀더 컬러, 선택 배경/텍스트 색

  • focus-visible: 키보드 포커스용 보더/링

  • aria-invalid: 에러 상태 보더/링(접근성 API)

  • file:: 파일 입력 기본 버튼 최소 보정


    커스텀 포인트(인풋)

  • 기본/포커스/에러 보더 정책: border·focus-visible:*·aria-invalid:*

  • 라운드/높이/패딩: 베이스 문자열

  • 라벨 컬러: 인풋 외부 요소 → 사용처에서 지정



5-3. 로컬 서버 렌더 테스트

page.tsxbutton.tsxinput.tsx를 불러와서 에러가 안나는지 테스트 해보았다.

// src/app/test-shad/page.tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
.
export default function Home() {
  const compact = false;
  return (
    <main className={cn("p-8 space-y-8", compact && "p-6 space-y-6")}>
      <section className="space-y-2">
        <label htmlFor="email" className="text-sm font-medium">이메일</label>
        <Input id="email" type="email" placeholder="you@example.com" />
      </section>
      <section className="space-x-2">
        <Button>버튼 테스트</Button>
        <Button variant="outline">아웃라인</Button>
      </section>
    </main>
  );
}

로컬 서버를 실행해서 인풋 + 버튼 2개(기본/아웃라인)이 정상적으로 렌더되는 것을 볼 수 있다.



5-4. 커스텀 테스트

커스텀마이즈

페이지마다 className을 덧붙이는 대신, 공통 규격을 소스 파일에 직접 심었다.
그래서 button.tsxinput.tsx필수만 남기고 슬림하게 손봤다.



왜 “소스 직접 수정”을 택했나

  • 일관성: 어디서 쓰든 같은 모양·동작 보장
  • 유지보수: 한 곳만 고치면 전역 반영
  • 속도: 사용처 코드는 <Button />, <Input />처럼 짧고 단순해짐



어디를 어떻게 건드렸나 (핵심만)

Button (src/components/ui/button.tsx)

  • shadcn 기본 cva 구조는 유지, 불필요한 variant/size는 정리

  • 팀에서 반복해 쓸 공동 스타일을 단일 variant로 고정

  • 즉, variants 블록에서 규격(라운드/높이/간격/상태)을 기본값으로 확정


    Input (src/components/ui/input.tsx)

  • 기본 유틸을 우리 베이스 규격으로 교체

  • 기본/포커스/에러 등 상태 규칙을 소스에 일괄 정의

  • 라벨 색은 인풋 바깥 요소라 사용처에서만 지정하도록 분리

포인트: “페이지 커스텀”이 아니라 “컴포넌트 기본값”을 바꿨다. → 공통 UX 확보



➕ 앞으로 스타일이 늘어나면 (운영 가이드)

  • 공통으로 반복button.tsxvariants에 새 variant 추가
    (예: soft, danger, ghost)

  • 크기 체계 필요sizesm / md / lg 추가하고 defaultVariants.size로 기본값 지정

  • 도메인 전용 버튼 → 원본을 감싼 래퍼 컴포넌트로 분리 (예: FilterButton)

  • 사이트 전반 톤 변경전역 토큰(globals.css)만 교체 (코드 수정 없이 전역 반영)

  • 일회성 실험 → 사용처에서 className 임시 적용(반복되면 즉시 variant/래퍼로 승격)

0개의 댓글