TailwindCSS v4에서의 ColorSet 적용기
왜 v4에서는 ColorSet이 예쁘게 적용되지 않는 거지...
TailwindCSS v4에서의 ColorSet 적용기
1. 서론
Tailwindcss 에서는 tailwind.config.ts에서 theme를 이용해서 colorset을 적용할 수 있습니다. 그래서 그것을 더 잘 다룰 수 있도록 도와주는 tailwindcss-themer 같은 테마 관련 라이브러리가 존재합니다. 저도 레퍼런스를 찾아보면서 너무 당연하게 사용할 수 있을 줄 알고, 금방 구현하리라고 생각했습니다. 당연히 레퍼런스는 Google Search & Chat GPT o3 + Gemini 2.5 pro 의 힘을 빌렸더랬죠. 그래서 당연히 최신 버전의 tailwind 레퍼런스라고 생각하고 넘기려고 했습니다…
다만 이게 v4로 넘어가면서 config.ts 파일 기반이 아닌, css기반으로 colorset 을 적용할 수 있도록 변경되어서 기존의 tailwindcss-themer 같은 라이브러리 활용이 불가능했죠. 어떻게든 방법을 강구하거나, 굴복하고 v3으로 다운그레이드를 하는 방법밖에는 없었습니다. 당연히 “채신기술=조은기술” 이라는 병맛주의 철학이 존재했기 때문에, v4를 포기할 수는 없었습니다…!!
2. 기존 v3 에서의 활용법
// tailwind.config.ts (v3 용)
import type { Config } from "tailwindcss";
const themer = require("tailwindcss-themer");
const lightColorSet = {
blue: "#27548a",
beige: "#f5eedc",
navy: "#183b4e",
yellow: "#dda853",
};
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
plugins: [
themer({
defaultTheme: {
name: "light",
extend: {
colors: lightColorSet,
},
},
themes: [],
}),
],
};
export default config;
tailwindcss v3에서는 다른 파일 건드릴 필요 없이 단순히 tailwind.config.ts 만 건드리고 치우면 됐습니다. 다른 레퍼런스를 찾아봐도, 이것보다 더 잘 설명되어 있는 게 없었어요. 다들 이걸 이용해서 구현하면 된다고 해서, 이게 업계 표준인 줄 알고 삽질을 며칠 동안 엄청 해댔던 것 같습니다… ㅠㅠ
3. v4 에서는 왜 그게 문제가 되냐?
어… v4에서는 전혀 먹지 않아서요. 다른 방법을 찾아야 했습니다. config.ts를 전혀 건드리지 않으면서도, 기존과 같이 테마에 따른 colorset을 적용할 수 있는 방법이요.
일단 중요한 관점이 있습니다. v4에서는 css 기반으로 작동하니 만큼, css 파일을 직접 뜯어서 코드를 작성해야 합니다. 그러나, 기존의 방법으로는, 컬러 팔레트를 쉽게 적용할 수 있고, 때문에 “color-shade” 템플릿을 쉽게 커스텀할 수도 있었다는 게 너무 부러웠습니다. 그렇다면 방법은 하나. css에다가 자동으로 기록해주는 스크립트를 실행시켜주는 번거로운 방법 뿐이었습니다.. ㅠㅠ
4. v4 에서의 해결 방법
설명에 앞서, 여기서는 아래의 의존성을 요구합니다.
pnpm add -D tsx @nextcss/color-tools
의존성을 해결했다면, 아래 본문으로 바로 들어가셔도 됩니다.
// app/globals.css
...
/* @theme-colors-start */
/* 🤖 이 블록은 스크립트에 의해 자동으로 관리됩니다. 직접 수정하지 마세요. */
/* @theme-colors-end */
...
gemini의 도움을 받아서 스크립트 작업을 했습니다.
일단 파서가 파싱할 수 있도록 css 파일에 마킹을 해둡니다. 저 구역만큼은 이제 제 컨트롤을 벗어났습니다. (아주 좋은 일이죠.) 그러면 이제 스크립트가 저 구역을 읽고 스타일을 파바박 기록할 수 있도록 구현해주는 일만 남았습니다.
// scripts/generate-theme.ts
import fs from 'fs';
import path from 'path';
import { lightColorSet } from '@/styles/color'; // 1단계 소스 파일은 동일
import { toneMap } from '@nextcss/color-tools';
console.log('🎨 Generating theme for Tailwind CSS v4...');
// --- 1. 전체 색상 팔레트 객체 생성 (이전과 동일) ---
const fullPalette = Object.entries(lightColorSet).reduce<Record<string, Record<string, string>>>(
(acc, [name, baseColor]) => {
acc[name] = { ...toneMap(baseColor), DEFAULT: baseColor };
return acc;
},
{},
);
// --- 2. @theme 블록 내용 생성 ---
// CSS 변수를 정의하는 @theme 블록을 문자열로 만듭니다.
let themeBlockContent = '@theme {\n';
for (const [colorName, shades] of Object.entries(fullPalette)) {
for (const [shadeName, value] of Object.entries(shades)) {
const key = shadeName === 'DEFAULT' ? '' : `-${shadeName}`;
themeBlockContent += ` --color-${colorName}${key}: ${value};\n`;
}
}
themeBlockContent += '}';
// --- 3. globals.css 파일 읽고 내용 교체하기 ---
const cssFilePath = path.join(process.cwd(), 'app', 'globals.css');
try {
const originalCssContent = fs.readFileSync(cssFilePath, 'utf-8');
// 마커 사이의 내용을 교체하기 위한 정규식
const markerRegex = /\/\* @theme-colors-start \*\/[\s\S]*?\/\* @theme-colors-end \*\//;
// 교체될 전체 블록 (마커 포함)
const replacementBlock = `/* @theme-colors-start */\n/* 🤖 이 블록은 스크립트에 의해 자동으로 관리됩니다. 직접 수정하지 마세요. */\n${themeBlockContent}\n/* @theme-colors-end */`;
if (!markerRegex.test(originalCssContent)) {
throw new Error("CSS markers '/* @theme-colors-start */' and '/* @theme-colors-end */' not found in globals.css.");
}
const newCssContent = originalCssContent.replace(markerRegex, replacementBlock);
fs.writeFileSync(cssFilePath, newCssContent);
console.log(`✅ Injected @theme block into: ${cssFilePath}`);
} catch (error) {
console.error(`❌ Error updating globals.css:`, error);
}
역시 gemini의 도움을 받았습니다. 함수에 주석도 예쁘게 달아두었기 때문에 함수만 그대로 따라가면 되겠습니다. styles/color에서 미리 지정해뒀던 colorset을 불러와서, 그것을 css파일에 마킹된 위치에 작성하게끔 구현되어 있습니다.
그러면 이제 스크립트를 작동시키게 하기만 하면 되겠죠. colorset을 지정하면, 명령어를 실행하는 방식으로 colorset 적용을 할 수 있도록 유도할 겁니다.
// package.json
{
...,
"scripts":{
...,
"generate:theme": "tsx scripts/generate-theme.ts"
}
}
tsx 모듈을 활용해서 ts 파일을 실행시킬 수 있도록 합니다. ts-node니 —esm이니, 뭐 다른 모듈을 통해서 실행시켜봤는데, 에러가 나더라고요. 그래서 tsx를 활용하도록 합시다. 저것을 실행하게 되면 global.css 에 마킹한 부분에 우리가 원하던 colorset 세팅이 아주 깔끔하게 들어갈 겁니다.
// src/styles/color.ts
export type ColorSet = {
blue: string;
beige: string;
navy: string;
yellow: string;
};
export const lightColorSet = {
blue: '#27548a',
beige: '#f5eedc',
navy: '#183b4e',
yellow: '#dda853',
} as const;
아 참, colorset 얘기를 안 한 것 같아요. 그냥 단순히 타입 지정해주고, 원하는 컬러를 박아두기만 하면 됩니다. 그러면 깔끔하게 알아서 shade까지 나누어 분석해줍니다.
5. theme는 어떻게 적용하냐?
아… 위에 코드는 theme가 전혀 고려가 안 되었던 것 같아서요. 그래서 코드를 처음부터 다시 작성하려고 해요. (눈물) 뭐, 방식은 틀리지 않았다고 생각하기에, 스크립트 수정 + 코드 적용 까지만 더 고려해주면 깔끔할 거라고 생각합니다.
일단 먼저, theme에 따른 colorset을 적용해야 하므로, src/styles/color.ts를 수정합니다.
// src/styles/color.ts
export type ColorSet = {
blue: string,
beige: string,
navy: string,
yellow: string,
};
export const ColorThemes = {
light: {
blue: "#27548a",
beige: "#f5eedc",
navy: "#183b4e",
yellow: "#dda853",
},
dark: {
blue: "#27548a",
beige: "#f5eedc",
navy: "#183b4e",
yellow: "#dda853",
},
};
이런 식으로, light모드와 dark 모드에 따라 구별할 수 있도록 구현을 해봤습니다. (일단 급하게 light와 dark를 나눠놨는데, 정작 dark colorset을 안 만들어서 같은 색상을 일단 넣었습니다… ㅠㅠ)
그리고, 이에 따라 스크립트를 그냥 쭉 작성하면 되겠죠.
// scripts/generate-theme.ts
import fs from 'fs';
import path from 'path';
import { ColorThemes } from '@/styles/color';
import { toneMap } from '@nextcss/color-tools';
console.log('🎨 Generating theme files with light/dark modes for Tailwind CSS v4...');
// --- 헬퍼 함수: Hex to RGB ---
// Tailwind의 투명도 수식자(e.g., bg-blue/50)를 지원하려면 rgb 값으로 변환해야 합니다.
const hexToRgb = (hex: string): string => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})` : '';
};
// --- 작업 1: CSS 변수 생성 ---
const cssBlocks = Object.entries(ColorThemes).map(([themeName, themeColors]) => {
const isLight = themeName === 'light';
const selector = isLight ? '@theme' : `.${themeName}`;
let cssBlock = `${selector} {\n`;
const fullPalette = Object.entries(themeColors).reduce<Record<string, any>>((acc, [name, baseColor]) => {
acc[name] = { ...toneMap(baseColor as string), DEFAULT: baseColor as string };
return acc;
}, {});
for (const [colorName, shades] of Object.entries(fullPalette)) {
for (const [shadeName, hexValue] of Object.entries(shades)) {
const key = shadeName === 'DEFAULT' ? '' : `-${shadeName}`;
// Tailwind v4는 rgb 컴포넌트를 값으로 사용합니다.
cssBlock += ` --color-${colorName}${key}: ${hexToRgb(hexValue as string)};\n`;
}
}
cssBlock += '}\n';
return cssBlock;
});
// `globals.css` 파일에 주입
const cssFilePath = path.join(process.cwd(), 'app', 'globals.css');
try {
const originalCssContent = fs.readFileSync(cssFilePath, 'utf-8');
const markerRegex = /\/\* @theme-colors-start \*\/[\s\S]*?\/\* @theme-colors-end \*\//;
const replacementBlock = `/* @theme-colors-start */\n/* 🤖 ... */\n${cssBlocks.join('\n')}\n/* @theme-colors-end */`;
fs.writeFileSync(cssFilePath, originalCssContent.replace(markerRegex, replacementBlock));
console.log(`✅ Injected CSS variables into: ${cssFilePath}`);
} catch (error) {
console.error(`❌ Error updating globals.css:`, error);
}
gemini가 한 번에 script를 주지 않아서, 프롬프트 삽질을 거진 2시간을 한 듯싶습니다… ㅠㅠ 아무튼 좋은 결과 얻어냈으니 된 거겠죠. 뭐, 방법은 같습니다. 아까처럼 colorset에서부터 shade 다 나눠서 global.css 에 주입해주면 되는 거죠. .dark 클래스도 적용할 수 있도록 구분해서 스크립트를 만들어주더라고요.
이렇게 해주면 아래 사진처럼 알아서 css 파일을 만들어줍니다.
이제 이걸 활용해서 개발을 하면 tailwindcss v4에서도 아주 깔끔하게 theme를 고려한 colorset을 적용할 수 있습니다.
6. 또 뭐가 남았나…?
lucide-react랑 radix icon 에도 color를 적용하고 싶었습니다. 이미 예쁘게 컴포넌트를 꾸미려고 아래처럼 customize 해둔 상황이었거든요.
https://velog.io/@moolbum/React-Lucide-React-Radix-Icon-%EC%95%84%EC%9D%B4%EC%BD%98-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%B2%98%EB%9F%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
그런데 이걸 읽어보시면, color prop 가 따로 있습니다. 근데 이걸 어떻게 인식시켜야 할지가 조금 애매하더라고요. (gemini가) 고민을 좀 해봤는데, 그냥 스크립트에 object까지 만들어주는 방식으로 구현하면 되겠더라고요.
// scripts/generate-theme.ts
...
// --- 작업 2: TypeScript 팔레트 객체 생성 ---
const finalPalette: Record<string, any> = {};
for (const [themeName, themeColors] of Object.entries(ColorThemes)) {
finalPalette[themeName] = {};
for (const [colorName, baseColor] of Object.entries(themeColors)) {
// 여기서는 실제 hex 코드를 값으로 가집니다.
finalPalette[themeName][colorName] = { ...toneMap(baseColor as string), DEFAULT: baseColor as string };
}
}
const tsFileContent = `// Auto-generated file...
export const colorSet = ${JSON.stringify(finalPalette, null, 2)} as const;
export type ThemeName = keyof typeof colorSet;
export type ColorName = keyof typeof colorSet['light'];
export type ShadeName<T extends ColorName> = keyof typeof colorSet['light'][T];
`;
// `generated-palette.ts` 파일 생성
const tsOutputPath = path.join(process.cwd(), 'src', 'lib', 'generated-palette.ts');
fs.mkdirSync(path.dirname(tsOutputPath), { recursive: true });
fs.writeFileSync(tsOutputPath, tsFileContent);
console.log(`✅ TypeScript palette object generated at: ${tsOutputPath}`);
역시 gemini가 아주 깔끔하게 만들어주더라고요. 이렇게 구현하면, 객체를 바로 활용할 수 있습니다.
그러면 이제 color를 바로 적용해주면 될 듯싶네요.
// src/components/icons/LucideIcon.tsx
import { ColorShadeFormat } from '@/lib/color';
import { cn } from '@/lib/utils';
import { icons } from 'lucide-react'; // lucide import
import { HTMLAttributes } from 'react';
export interface LucideIconProps extends HTMLAttributes<HTMLOrSVGElement> {
name: keyof typeof icons;
color?: ColorShadeFormat;
size?: number;
}
export default function LucideIcon({ name, color = 'blue-500', size = 16, ...props }: LucideIconProps) {
const SelectLucideIcon = icons[name];
const isClickEvent = !!props.onClick;
const pointerStyle = isClickEvent ? 'cursor-pointer' : '';
return (
<SelectLucideIcon
color={colorSetTheme[color]}
size={size}
className={cn(pointerStyle, props.className)}
{...props}
/>
);
}
음… color prop에 올바르게 넣어줄 수 있도록 parser가 필요해 보이네요! colorSetTheme는 theme - color - shade 순으로 속성을 가지고 있으니, 그것에 맞추어 뽑을 수 있도록 함수를 짜면 될 듯싶습니다.
import { ColorSet } from '@/styles/color';
import { colorSet } from '@/lib/generated-palette';
type ColorName = keyof ColorSet;
type ShadeName = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
export const parseColorSet = (
colorShade: ColorShadeFormat,
isDark: boolean = false,
defaultColor: string = '#27548a',
): string => {
const parts = colorShade.split('-');
if (parts.length !== 2) return defaultColor;
const [color, shade] = parts;
const colorSetTheme = colorSet[isDark ? 'dark' : 'light'];
if (Object.keys(colorSetTheme).includes(color as any) && Object.keys(colorSetTheme[color]).includes(shade as any)) {
return colorSetTheme[color][shade];
}
return defaultColor;
};
export type ColorShadeFormat = `${ColorName}-${ShadeName}`;
아무리봐도 refactoring 해주어야 하는 의존성 & 네이밍이지만, 일단 오늘 이거 구현한다고 시간을 너무 많이 써서요… ㅠㅠ 추후에 refactoring 하는 것으로 합니다. (나쁜 버릇)
parser가 안전하게 parsing 할 수 있도록 그냥 구현해둔 겁니다. 하는 김에 theme도 구분할 수 있게 따로 isDark prop을 빼뒀습니다. 그냥 나중에 class 에서 “dark”가 있는지만 문자열 탐색을 해주면 될 듯싶어서요. 더 좋은 방법이 분명 있을 텐데, 다시 한 번 말하지만 시간을 너무 많이 써서요… ㅠㅠ
어찌됐건, parsing을 완료했으니, 그냥 함수를 대입해주기만 하면 됩니다.
// src/components/icons/LucideIcon.tsx
...
return (
<SelectLucideIcon
color={parseColorSet(color, props.className?.includes('dark'))}
size={size}
className={cn(pointerStyle, props.className)}
{...props}
/>
);
이러면 lucide-react 에 대해서 color를 깔끔하게 적용되는 걸 확인할 수 있었습니다. LocalIcon에다가 적용하면 radix icon 도 커버를 할 수 있겠죠.
7. 결론
여기까지가 tailwindcss v4에서 custom colorset 적용기였습니다. 뭔가 삽질의 연속인 듯싶어서, 기록을 하지 않으면 안 될 것 같았습니다. v3 이랑 v4이 구조부터 다를 줄은 상상도 못 해서(레퍼런스 이슈), 시간과 노력을 너무 헛되게 쓴 것 같습니다. 하지만 이렇게 구현을 한 번 해보면, 다음에는 tailwindcss에서의 theme 적용에 더 적은 시간을 쓸 수 있지 않을까 싶어요. 무엇보다, 다양한 색상 팔레트가 장점이었던 tailwindcss를 십분 살릴 수 있는 방법을 어떻게든 v4에서 찾았다는 점이 중요하다고 생각합니다.