탐색

Migration

shadcn/ui에서 마이그레이션

프리미티브가 동작을 유지하고 테마 변수를 계속 공유하며 호출자의 재정의를 캐스케이드 레이어로 해결하면서 shadcn/ui 컴포넌트를 Master CSS로 옮깁니다.

이 페이지의 내용

shadcn/ui는 컴포넌트 소스를 프로젝트에 복사합니다. 각 컴포넌트는 Tailwind CSS 클래스, cva 변형, tailwind-merge 기반의 cn() 헬퍼, :root와 .dark의 테마 변수, Radix UI·Base UI·React Aria의 프리미티브를 결합합니다. 프리미티브가 포커스 관리, 키보드 처리, ARIA 속성, 상태를 계속 담당하고 Master CSS가 스타일을 넘겨받을 수 있습니다.

Master CSS는 Tailwind 클래스 구문을 읽지 않으므로 컴포넌트를 그대로 재사용하지 않고 다시 작성합니다. 아직 변환하지 않은 컴포넌트는 계속 Tailwind로 빌드하고, 컴포넌트를 하나씩 변환하며, 마지막 컴포넌트를 변환한 뒤 Tailwind를 제거합니다. 이 가이드는 shadcn/ui에 특화된 부분을 다룹니다. 유틸리티, 변형, 테마 값 전반은 Tailwind CSS에서 마이그레이션에서 다룹니다.


#마이그레이션 전략

기준 상태, 작업 단위, 제거 검사에는 공통 마이그레이션 절차를 사용합니다. shadcn/ui 컴포넌트는 다음 부분으로 구성됩니다.

부분이동 또는 유지
Radix UI, Base UI 또는 React Aria 프리미티브유지합니다. 포커스, 키보드 동작, ARIA, 스타일이 선택하는 상태 속성을 담당합니다.
:root와 .dark의 테마 변수변수를 유지하고 @theme inline으로 Master CSS 토큰에 대응시킵니다.
cva 기본 클래스와 변형@layer components로 옮기고 컴포넌트의 data-* 속성으로 선택합니다.
cn()과 tailwind-merge호출자의 재정의는 캐스케이드 레이어가 해결하게 합니다. cn()은 더 이상 클래스를 제거할 필요가 없습니다.
tw-animate-css 클래스프리미티브의 열림·닫힘 상태로 선택하는 네이티브 키프레임으로 바꿉니다.
shadcn CLI가 추가한 컴포넌트새 출력은 변환하기 전까지 변환되지 않은 Tailwind 소스로 다룹니다.

Button은 첫 컴포넌트로 적합합니다. 변형, 크기, 상태가 다른 컴포넌트의 패턴 대부분을 포괄하며 다른 컴포넌트가 Button을 재사용합니다.


#shadcn/ui 빌드와 함께 Master CSS 로드

shadcn/ui 스타일시트를 Tailwind 진입점으로 유지하고 일반 CSS로 빌드합니다. Master CSS가 @theme, @custom-variant, @apply 같은 Tailwind 지시문을 컴파일해서는 안 됩니다. 두 시스템은 이 이름 중 일부를 서로 다른 계약으로 사용합니다.

변환한 모든 컴포넌트를 Tailwind 스캔에서 제외합니다.

src/shadcn-tailwind.css
@import "tailwindcss";@import "tw-animate-css";@source not "./components/ui/button.tsx";@custom-variant dark (&:is(.dark *));@theme inline {  --color-background: var(--background);  --color-primary: var(--primary);  /* …the rest of the shadcn/ui color and radius mappings */}

Master CSS 진입점을 만들고 다른 어떤 내용보다 먼저 레이어 순서를 선언합니다.

src/app.css
@layer properties, theme, base, defaults, components, utilities;@import "@master/css";@import "./components/ui/button.css";

이 문은 맨 앞에 와야 합니다. Tailwind 출력은 defaults 없이 properties와 theme, base, components, utilities를 선언합니다. 뒤쪽 문에서 처음 등장한 레이어는 순서의 맨 끝으로 갑니다. 이 문이 없으면 defaults가 utilities보다 우선하며, 이전 Safari와 Firefox를 위한 Tailwind의 properties 폴백도 마찬가지로 우선하게 됩니다.

앱 루트에서 Master CSS 진입점 다음에 Tailwind 출력을 가져옵니다.

src/main.tsx
import './app.css'import './shadcn-tailwind.generated.css'

Master CSS는 변환하지 않은 컴포넌트를 포함해 모든 애플리케이션 파일을 스캔합니다. text-sm, shadow-xs, inline-flex처럼 Tailwind 클래스 이름을 인식하면 같은 utilities 레이어에 해당 이름의 자체 규칙을 생성합니다. 두 시스템은 --text-sm--line-height 같은 일부 테마 변수도 똑같이 정의합니다. Tailwind 출력을 나중에 로드하면 Tailwind의 규칙과 변수가 우선하므로 변환하지 않은 컴포넌트는 모양을 유지합니다. Tailwind를 제거하기 전까지는 이런 이름을 사용하는 변환된 요소도 Tailwind의 값을 받습니다. 컴포넌트에서 Master CSS가 인식하는 이름을 나열하려면 npx master-css inspect src/components/ui/card.tsx를 실행하고 scanner.classes.valid를 확인하세요.

마지막으로 Master CSS 기본 스타일을 Tailwind Preflight와 비교합니다. Master CSS는 body에 font-family와 line-height: 1.2를 지정하고, Preflight는 html에 글꼴과 line-height: 1.5를 지정합니다. 둘 다 로드된 동안 기존 문서 타이포그래피를 유지하려면 body가 이 값을 상속하게 합니다.

src/app.css
@layer defaults {  body {    font-family: inherit;    line-height: inherit;  }}

Tailwind를 제거할 때 이 규칙도 제거하고 문서 타이포그래피를 의도적으로 선택하세요.


#테마 변수 대응

shadcn/ui 스타일시트의 :root와 .dark 블록을 변경 없이 Master CSS 진입점으로 옮깁니다. 그러면 두 컴파일러가 같은 변수를 읽습니다. shadcn/ui가 Tailwind에 제공하는 것과 같은 @theme inline 블록으로 이 변수를 Master CSS 토큰에 대응시킵니다.

src/app.css
:root {  --radius: 0.625rem;  --background: oklch(1 0 0);  --foreground: oklch(0.145 0 0);  --primary: oklch(0.205 0 0);  --primary-foreground: oklch(0.985 0 0);  --accent: oklch(0.97 0 0);  --accent-foreground: oklch(0.205 0 0);  --destructive: oklch(0.577 0.245 27.325);  --border: oklch(0.922 0 0);  --input: oklch(0.922 0 0);  --ring: oklch(0.708 0 0);}.dark {  --background: oklch(0.145 0 0);  --foreground: oklch(0.985 0 0);  --primary: oklch(0.922 0 0);  --primary-foreground: oklch(0.205 0 0);  --accent: oklch(0.269 0 0);  --accent-foreground: oklch(0.985 0 0);  --destructive: oklch(0.704 0.191 22.216);  --border: oklch(1 0 0 / 10%);  --input: oklch(1 0 0 / 15%);  --ring: oklch(0.556 0 0);}@theme inline {  --color-background: var(--background);  --color-foreground: var(--foreground);  --color-primary: var(--primary);  --color-primary-foreground: var(--primary-foreground);  --color-accent: var(--accent);  --color-accent-foreground: var(--accent-foreground);  --color-destructive: var(--destructive);  --color-border: var(--border);  --color-input: var(--input);  --color-ring: var(--ring);  --radius-sm: calc(var(--radius) * 0.6);  --radius-md: calc(var(--radius) * 0.8);  --radius-lg: var(--radius);}

이제 bg-primary, fg-primary-foreground, b-input, r-md 같은 클래스는 var(--primary), var(--primary-foreground), var(--input), calc(var(--radius) * 0.8)을 생성합니다. 색 불투명도는 평소처럼 동작합니다. bg-primary/.9는 변수를 투명도와 혼합합니다.

inline 수정자를 유지하세요. 이 수정자가 없으면 Master CSS는 :root에 --color-primary: var(--primary)를 정의하고, 브라우저는 그곳에서 var(--primary)를 한 번만 해석합니다. 그러면 중첩된 요소의 .dark 클래스가 --primary는 바꾸지만 --color-primary는 바꾸지 않습니다. 인라인 토큰은 각 선언에 var(--primary)를 넣으므로 가장 가까운 .dark 조상이 적용됩니다.

예제의 반경 토큰은 프리셋의 sm, md, lg 반경을 대체합니다. card, popover, muted, chart-*, sidebar-* 등 나머지 shadcn/ui 변수도 같은 방식으로 대응시킵니다. shadcn/ui의 @layer base 규칙도 네이티브 CSS로 Master CSS 진입점에 옮깁니다.

src/app.css
@layer defaults {  * {    border-color: var(--border);    outline-color: color-mix(in oklab, var(--ring) 50%, transparent);  }  body {    background-color: var(--background);    color: var(--foreground);  }}

#컴포넌트 기본 스타일을 @layer components로 이동

shadcn/ui 컴포넌트는 호출자가 조정할 수 있게 합니다. <Button className="px-6">은 px-6을 cn()에 전달하고, tailwind-merge는 문자열에서 Button 자체의 px-4를 제거합니다. Master CSS는 클래스 문자열을 병합하지 않습니다. 대신 컴포넌트 자체 스타일을 @layer components에 둡니다. 호출자의 유틸리티는 레이어 순서에서 뒤에 오는 @layer utilities에 생성되므로 아무것도 제거하지 않고 적용됩니다.

재정의는 레이어가 결정합니다

두 규칙은 모두 스타일시트에 남아 있고 둘 다 버튼과 일치합니다. 레이어 순서에서 utilities가 components 뒤에 오므로 컴포넌트의 클래스를 제거하지 않아도 호출자의 패딩이 적용됩니다.

텍스트 설명
  1. 컴포넌트 기본값. Button 자체 스타일은 @layer components 안에서 [data-slot=button]을 선택하고 padding-inline: 1rem을 지정합니다.
  2. 호출자 조정. 호출자는 className으로 px-lg를 전달합니다. Master CSS는 이를 @layer utilities에 padding-inline: 1.5rem으로 생성합니다.
  3. 레이어 순서. utilities가 components 뒤에 오므로 루트 크기가 16px일 때 버튼의 계산값은 24px입니다. 문자열에서 제거되는 클래스는 없습니다.

Radix UI Button은 이미 data-slot, data-variant, data-size를 렌더링합니다. 컴포넌트 옆의 네이티브 스타일시트에서 이 속성을 선택합니다. 다음 규칙은 Button의 기본 스타일, 기본 크기, default와 outline 변형을 재현합니다.

src/components/ui/button.css
@layer components {  [data-slot="button"] {    display: inline-flex;    flex-shrink: 0;    align-items: center;    justify-content: center;    gap: 0.5rem;    border-radius: calc(var(--radius) * 0.8);    font-size: 0.875rem;    line-height: calc(1.25 / 0.875);    font-weight: 500;    white-space: nowrap;    outline: none;    transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);    &:focus-visible {      border-color: var(--ring);      outline: 3px solid color-mix(in oklab, var(--ring) 50%, transparent);    }    &[aria-invalid="true"] {      border-color: var(--destructive);      outline-color: color-mix(in oklab, var(--destructive) 20%, transparent);      .dark & {        outline-color: color-mix(in oklab, var(--destructive) 40%, transparent);      }    }    &:disabled {      pointer-events: none;      opacity: 0.5;    }    & svg {      width: 1rem;      height: 1rem;      flex-shrink: 0;      pointer-events: none;    }  }  [data-slot="button"][data-size="default"] {    height: 2.25rem;    padding: 0.5rem 1rem;    &:has(> svg) {      padding-inline: 0.75rem;    }  }  [data-slot="button"][data-variant="default"] {    background-color: var(--primary);    color: var(--primary-foreground);    @media (hover: hover) {      &:hover {        background-color: color-mix(in oklab, var(--primary) 90%, transparent);      }    }  }  [data-slot="button"][data-variant="outline"] {    border-width: 1px;    border-style: solid;    background-color: var(--background);    box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);    @media (hover: hover) {      &:hover {        background-color: var(--accent);        color: var(--accent-foreground);      }    }    .dark & {      border-color: var(--input);      background-color: color-mix(in oklab, var(--input) 30%, transparent);      @media (hover: hover) {        &:hover {          background-color: color-mix(in oklab, var(--input) 50%, transparent);        }      }    }  }}

다른 크기와 변형도 같은 패턴을 따릅니다. outline 변형은 테두리 너비와 스타일만 지정합니다. 색은 defaults의 * 규칙에서 오므로 포커스와 aria-invalid 색이 이를 대체할 수 있습니다. 포커스 링은 Tailwind의 box-shadow 링 대신 outline을 사용하므로 변형의 그림자와 결합할 필요가 없습니다.

컴포넌트에는 더 이상 cva나 cn()이 필요하지 않습니다. 컴포넌트는 속성을 렌더링하고 className을 다른 props와 함께 전달합니다.

src/components/ui/button.tsx
import * as React from 'react'import { Slot } from 'radix-ui'type ButtonProps = React.ComponentProps<'button'> & {  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'  size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'  asChild?: boolean}export function Button({ variant = 'default', size = 'default', asChild = false, ...props }: ButtonProps) {  const Comp = asChild ? Slot.Root : 'button'  return <Comp data-slot="button" data-variant={variant} data-size={size} {...props} />}

호출자는 Master CSS 클래스로 Button을 조정하며, 클래스 순서는 상관없습니다.

<Button variant="outline" className="px-lg r-lg">  Edit profile</Button>

px-lg와 r-lg가 컴포넌트의 패딩과 반경보다 우선하고 변형의 테두리와 색은 유지됩니다. 호출자는 클래스를 생략하는 방식으로 컴포넌트 선언을 제거할 수 없습니다. 스타일을 제거하거나 초기화하는 대안이 필요하면 컴포넌트에 변형, 크기 또는 속성을 마련하세요.

일부 컴포넌트는 data-slot만 렌더링합니다. 예를 들어 Base UI Button은 변형을 렌더링하지 않습니다. 스타일에 필요한 속성을 추가하세요. 컴포넌트 소스는 직접 변경해도 됩니다. 다른 컴포넌트는 링크나 트리거를 버튼처럼 꾸미기 위해 buttonVariants()를 호출합니다. 이런 요소에는 대신 data-slot="button"과 대응하는 data-variant, data-size를 렌더링하세요.

컴포넌트가 여전히 조건부 클래스를 결합하는 곳에서는 cn()을 일반 clsx 호출로 유지할 수 있습니다. 코드 린트와 언어 서비스는 기본적으로 cn() 호출 안의 클래스를 검사합니다.


#shadcn/ui 변형 변환

컴포넌트 스타일시트 안에서는 shadcn/ui 변형을 네이티브 선택자나 조건으로 작성합니다. 마크업에서는 대응하는 Master CSS 접미사를 사용합니다. 선택자 열은 컴포넌트 CSS를, 클래스 열은 같은 의도를 마크업으로 보여 줍니다.

shadcn/ui 패턴컴포넌트 CSSMaster CSS 클래스
data-[state=open]:bg-accent&[data-state="open"]bg-accent[data-state=open]
data-open:bg-accent (Base UI)&[data-open]bg-accent[data-open]
aria-invalid:border-destructive&[aria-invalid="true"]b-destructive[aria-invalid=true]
has-[>svg]:px-3&:has(> svg)(padding-inline:.75rem):has(>svg)
[&_svg]:pointer-events-none& svg(pointer-events:none)_svg
*:[a]:underline& > aunderline>a
hover:bg-primary/90@media (hover: hover) { &:hover }bg-primary/.9:hover@media(hover:hover)
focus-visible:ring-[3px] ring-ring/50outline을 사용한 &:focus-visibleoutline(3):focus-visible outline-solid:focus-visible outline-ring/.5:focus-visible
dark:bg-input/30.dark &bg-input/.3@apply(--manual-dark)
group-data-[collapsible=icon]:hidden[data-collapsible="icon"] &hidden@apply(--sidebar-icon)
sm:max-w-lg@media (width >= 40rem)(max-width:32rem)@tailwind-sm
size-4width: 1rem; height: 1remw(16) h(16)

@apply(...)와 @tailwind-sm 접미사를 사용하려면 Master CSS 진입점에 정의가 필요합니다.

src/app.css
@mixin --manual-dark { &:where(.dark, .dark *) { @contents; } }@mixin --sidebar-icon { [data-collapsible="icon"] & { @contents; } }@custom-media --tailwind-sm (width >= 40rem);

Tailwind의 sm은 40rem부터 시작하고, Master CSS 프리셋의 @sm은 다른 너비에서 시작합니다. 명명된 조건으로 원래 임계값을 유지하세요. Master CSS에는 group-*나 peer-* 변형이 없습니다. --sidebar-icon 같은 선택자 믹스인은 group 클래스 없이 조상의 속성을 직접 검사합니다.


#tw-animate-css 애니메이션 교체

대화상자, 팝오버, 메뉴, 시트는 data-[state=open]:animate-in, data-[state=closed]:zoom-out-95 같은 tw-animate-css 클래스로 애니메이션됩니다. 같은 상태 속성으로 선택하는 네이티브 키프레임으로 바꿉니다.

src/components/ui/dialog.css
@keyframes dialog-overlay-in { from { opacity: 0; } }@keyframes dialog-overlay-out { to { opacity: 0; } }@keyframes dialog-content-in { from { opacity: 0; scale: 0.95; } }@keyframes dialog-content-out { to { opacity: 0; scale: 0.95; } }@layer components {  [data-slot="dialog-overlay"] {    position: fixed;    inset: 0;    z-index: 50;    background-color: rgb(0 0 0 / 0.5);    &[data-state="open"] { animation: dialog-overlay-in 150ms; }    &[data-state="closed"] { animation: dialog-overlay-out 150ms; }  }  [data-slot="dialog-content"] {    &[data-state="open"] { animation: dialog-content-in 200ms; }    &[data-state="closed"] { animation: dialog-content-out 200ms; }  }  @media (prefers-reduced-motion: reduce) {    [data-slot="dialog-overlay"],    [data-slot="dialog-content"] {      animation-duration: 1ms;    }  }}

닫힘 애니메이션은 동작의 일부입니다. Radix UI는 data-state="closed"가 시작한 애니메이션이 끝날 때까지 닫히는 요소를 마운트된 상태로 유지하며, Base UI도 같은 방식으로 요소의 애니메이션을 기다립니다. 종료 애니메이션이 없으면 요소는 즉시 사라집니다. Base UI 컴포넌트는 data-state 대신 [data-open]과 [data-closed]를 선택합니다.

키프레임 이름은 컴포넌트에 특화된 이름으로 유지하세요. Master CSS는 네이티브 키프레임을 작성한 그대로 유지하며 이름을 바꾸지 않습니다.


#shadcn CLI 출력을 검토 가능하게 유지

npx shadcn add는 마이그레이션 중에도 계속 동작합니다. 이 명령은 새 컴포넌트를 Tailwind 구문으로 작성하고, Tailwind 스타일시트 경로를 components.json에서 읽습니다. tailwind.css가 src/shadcn-tailwind.css를 가리키도록 설정해 CLI가 추가한 테마 변수가 Tailwind 진입점에 들어가게 합니다. 기존 변수와 마찬가지로 이 변수도 Master CSS 진입점으로 옮기세요.

새 컴포넌트는 변환되지 않은 소스입니다. Tailwind가 이를 빌드하며, 변환된 컴포넌트와 함께 계속 동작합니다. 이미 변환한 컴포넌트를 다시 추가하고 덮어쓰기를 수락하면 파일이 Tailwind 버전으로 교체됩니다. 이 변경을 diff로 검토하고 의도한 차이를 옮긴 뒤 변환 결과를 유지하세요.


#검증 후 Tailwind 제거

변환한 각 컴포넌트를 시작할 때의 버전과 비교합니다.

  • 라이트·다크 테마, 각 변형과 크기, 호버·focus-visible·비활성·aria-invalid 상태를 비교합니다.
  • 키보드와 포인터로 대화상자, 메뉴, 팝오버를 열고 닫습니다. 포커스가 이동했다가 돌아오는지, 종료 애니메이션이 끝나는지 확인합니다.
  • 호출자에서 className 조정을 전달하고 컴포넌트의 다른 선언이 유지되는지 확인합니다.
  • 두 시스템이 모두 생성하는 클래스 이름을 사용하는 요소는 브라우저 개발자 도구에서 계산값을 확인합니다.

마지막 컴포넌트를 변환한 뒤 앱 루트에서 Tailwind import를 제거하고, 이어서 src/shadcn-tailwind.css, @source not 목록, Tailwind 패키지를 제거합니다. 어떤 컴포넌트도 tw-animate-css, tailwind-merge, class-variance-authority를 가져오지 않으면 이 패키지도 제거합니다. 공존 단계에서 추가한 body 상속 규칙을 삭제하고, Preflight가 더 이상 리셋을 제공하지 않으므로 페이지를 다시 확인합니다. 나머지 단계는 Tailwind 제거를 따릅니다.


#관련 참고 자료