개발/Frontend

shadcn/ui 커스텀 테마 만들기 CSS 변수 가이드

반응형

shadcn/ui 커스텀 테마는 CSS 변수 시스템을 이해해야 진짜 자유롭게 만들 수 있어요. 기본 테마만 쓰다가 브랜드 컬러로 갈아끼우려고 보면 어디를 어떻게 건드려야 할지 막막한 경우가 많은데, 토큰 구조만 잡히면 그다음부터는 정말 단순해져요.

 

이 글에서는 shadcn/ui가 색상을 어떻게 관리하는지, 라이트와 다크 모드를 어떻게 분기하는지, 그리고 OKLCH 기반의 커스텀 토큰을 어떻게 추가하는지를 실제로 쓸 수 있는 코드 위주로 정리했어요. Tailwind v4 기준이라 @theme inline 디렉티브를 쓰는 최신 방식이에요.

 

shadcn/ui 테마가 CSS 변수로 돌아가는 이유

처음 shadcn/ui를 깔면 globals.css:root.dark가 정의되어 있고, 컴포넌트에는 bg-primary, text-primary-foreground 같은 클래스가 박혀 있어요. 컴포넌트 코드를 안 건드리고 색만 바꾸고 싶다면 결국 이 CSS 변수만 손대면 되는 구조예요.

 

shadcn/ui는 헤드리스 라이브러리라 컴포넌트 코드 자체가 내 프로젝트 안으로 들어와요. 그래서 토큰을 잘 잡아두면 디자인 시스템처럼 쓸 수 있고, 반대로 토큰 안 잡고 컴포넌트마다 색을 직접 박으면 그 순간 디자인 시스템이 아니라 그냥 컴포넌트 모음집이 돼버려요.

 

핵심 색상 토큰부터 정리

shadcn/ui 기본 토큰은 거의 다 "표면 색상 + foreground" 쌍으로 묶여요. 이게 처음에는 좀 어색한데 익숙해지면 정말 편해요.

기본 토큰 종류:

  • background : foreground : 페이지 전체 배경과 본문 텍스트
  • card : card-foreground : 카드, 패널, 모달 컨테이너
  • popover : popover-foreground : 드롭다운, 툴팁
  • primary : primary-foreground : 메인 액션 버튼, 브랜드 컬러
  • secondary : secondary-foreground : 보조 버튼, 배지
  • muted : muted-foreground : 비활성 텍스트, 플레이스홀더, 부차적 정보
  • accent : accent-foreground : 호버 상태, 강조 백그라운드
  • destructive : 삭제, 위험 액션 (foreground 없이 단독으로 쓰는 경우도 있음)
  • border, input, ring : 테두리, 인풋 보더, 포커스 링

여기서 핵심은 컴포넌트 안에서 bg-primary text-primary-foreground 처럼 항상 페어로 쓴다는 거예요. 그래서 라이트/다크 모드를 만들 때 색상 두 개만 같이 뒤집으면 대비가 자동으로 맞춰져요. 텍스트 색을 따로 신경 안 써도 된다는 뜻이라 진짜 편해요.

 

라이트와 다크 모드 토큰 정의

globals.css에서 :root는 라이트, .dark는 다크 모드 값을 잡아요. 다크 모드 토글은 html 태그에 dark 클래스를 붙였다 뗐다 하는 방식이라 next-themes 같은 라이브러리랑 그대로 호환돼요.

 

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.625rem;
}

.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);
  --border: oklch(0.269 0 0);
  --ring: oklch(0.439 0 0);
}

 

다크 모드 색을 정할 때 라이트 모드의 lightness 값을 그냥 1에서 빼는 식으로 잡으면 처음 출발점으로는 나쁘지 않아요. 다만 다크 모드는 채도(chroma)를 라이트보다 살짝 낮추는 게 눈에 편하더라구요. 라이트에서 chroma 0.2를 썼다면 다크에서는 0.15 정도로 떨어뜨리는 식이에요. 채도가 그대로면 다크 모드에서 색이 형광펜처럼 튀어 보이는 경우가 자주 생겨요.

 

OKLCH가 왜 표준이 됐는가

Tailwind v4부터 shadcn/ui 기본 색상이 HSL에서 OKLCH로 바뀌었어요. 처음 보면 숫자가 낯설지만 익숙해지면 HSL보다 훨씬 직관적이에요.

 

OKLCH 값은 세 가지로 구성돼요.

  • L (lightness) : 0부터 1까지, 명도. 0이 완전 검정, 1이 완전 흰색
  • C (chroma) : 0부터 0.4 정도까지, 채도
  • H (hue) : 0부터 360까지, 색상환 각도

OKLCH의 장점은 lightness 값이 사람 눈에 인지되는 밝기랑 거의 일치한다는 점이에요. HSL에서는 같은 lightness 50%여도 노란색이랑 파란색이 전혀 다른 밝기로 보이는데, OKLCH는 그게 일관적이에요. 그래서 색상 팔레트를 일정한 스텝으로 만들 때 결과가 자연스러워요.

 

처음 시작할 때는 oklch.com 같은 색상 피커에서 브랜드 컬러의 OKLCH 값을 찾아두면 편해요. 거기서 hue를 고정하고 lightness만 단계적으로 바꾸면 그 색상의 자연스러운 음영 팔레트가 나와요.

 

@theme inline 디렉티브 연결하기

Tailwind v4에서는 CSS 변수를 그대로 두면 bg-primary 같은 유틸리티를 못 만들어요. @theme 디렉티브로 Tailwind에 노출시켜야 해요. 그런데 그냥 @theme를 쓰면 빌드 시점에 값이 박혀버리니까, 런타임에 다크 모드 토글이 되도록 하려면 반드시 @theme inline을 써야 해요.

 

@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

 

여기서 @theme inline은 값을 즉시 박지 않고 var() 참조 자체를 그대로 보존해줘요. 그 덕분에 .dark 클래스가 붙는 순간 모든 토큰이 자동으로 다크 값으로 바뀌어요. 이 차이를 놓치면 다크 모드 토글이 작동 안 해서 한참 헤매게 돼요.

 

커스텀 토큰 추가하는 흐름

브랜드에서 warning, success 같은 색이 추가로 필요하면 똑같이 페어로 만들어주면 돼요.

:root {
  --warning: oklch(0.84 0.16 84);
  --warning-foreground: oklch(0.28 0.07 46);
  --success: oklch(0.72 0.17 145);
  --success-foreground: oklch(0.98 0 0);
}

.dark {
  --warning: oklch(0.78 0.13 84);
  --warning-foreground: oklch(0.15 0.03 46);
  --success: oklch(0.65 0.14 145);
  --success-foreground: oklch(0.99 0 0);
}

@theme inline {
  --color-warning: var(--warning);
  --color-warning-foreground: var(--warning-foreground);
  --color-success: var(--success);
  --color-success-foreground: var(--success-foreground);
}

이렇게만 하면 bg-warning text-warning-foreground가 바로 동작해요. 컴포넌트 코드는 한 줄도 안 건드려도 돼요.

 

다만 커스텀 토큰은 처음에 너무 많이 만들지 마세요. 5개 넘어가기 시작하면 컴포넌트마다 어떤 색을 쓸지 결정하는 데 시간이 더 들고, 디자이너랑 의견 충돌도 늘어나요. 정말 자주 쓰는 의미 단위만 토큰화하고 나머지는 그냥 기본 토큰 조합으로 처리하는 게 유지보수에 훨씬 좋아요.

 

components.json 설정 확인

shadcn/ui 초기 세팅 단계에서 components.jsontailwind.cssVariablestrue인지 확인하는 게 중요해요. 기본값이 true이긴 한데, 이 값이 false면 컴포넌트 코드 안에 색상이 인라인 클래스로 박혀 나와서 토큰 시스템이 깨져요.

{
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  }
}

baseColor는 처음 컴포넌트를 추가할 때 어떤 컬러 팔레트를 가져올지 결정하는 값이에요. neutral, stone, zinc, slate, gray 같은 옵션 중에 고를 수 있고, 이건 한번 정하면 나중에 바꾸기는 좀 번거롭긴 해요. 그래도 어차피 globals.css에서 변수만 갈아끼우면 되니까 크게 걱정할 필요는 없어요.

 

설치는 보통 이렇게 시작해요.

pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button card input

 

실전에서 자주 놓치는 포인트

ring 색상을 primary랑 똑같이 두는 경우가 많은데, 폼이 강조색으로 가득한 페이지에서는 포커스 링이 안 보여요. ring은 primary랑 살짝 다른 톤으로 빼두는 게 접근성에도 좋아요.

 

border 색상도 다크 모드에서 너무 어둡게 잡으면 카드 경계가 사라져서 레이아웃이 뭉개져 보여요. 다크 배경보다 lightness가 0.1 정도 위인 값으로 잡는 게 안정적이에요.

 

마지막으로 색상을 정할 때는 실제 컴포넌트(버튼, 인풋, 카드)를 한 화면에 다 띄워놓고 같이 조정해야 해요. 한 컴포넌트만 보면서 색을 맞추면 전체 화면에서 봤을 때 위화감이 생기는 경우가 많거든요. tweakcn, shadcn theme generator 같은 사이트에서 실시간으로 미리보기 하면서 잡으면 시간이 많이 절약돼요.

 

마무리

shadcn/ui 커스텀 테마는 결국 CSS 변수 토큰을 어떻게 설계하느냐의 문제예요. 토큰을 페어로 묶고, OKLCH로 일관된 명도 스케일을 잡고, @theme inline으로 Tailwind에 노출시키는 이 세 가지만 익숙해지면 그다음부터는 어떤 디자인이든 그대로 옮길 수 있어요.

 

처음에는 기본 토큰으로 시작해서, 정말 필요할 때만 커스텀 토큰을 추가하는 방향으로 가는 게 좋아요. 토큰을 적게 가져갈수록 디자인 시스템은 더 단단해져요.

반응형