HeroUI v3 + Tailwind v4의 CSS-First 아키텍처 — tailwind.config.js 없이 접근성·다크 모드·번들 최적화까지
Tailwind CSS v4가 나왔을 때 솔직히 살짝 당황했습니다. tailwind.config.js에 공들여 정의해둔 테마 설정들, 플러그인 방식으로 엮어둔 컴포넌트 라이브러리들... 다 어떻게 되는 건지 막막하더라고요. 그런데 HeroUI v3를 실제 프로젝트에 써보면서 생각이 바뀌었습니다. 이 조합의 핵심은 CSS 변수 중심의 단순한 아키텍처로, 접근성·다크 모드·번들 크기 세 가지를 동시에 챙길 수 있다는 점입니다. 설정 파일이 줄어들고, 스타일 레이어가 CSS 계층 구조 그대로 드러나는 경험이 꽤 인상적이었습니다.
HeroUI는 구 NextUI에서 리브랜딩된 React UI 컴포넌트 라이브러리입니다. 2026년 출시된 v3에서는 React 19와 Tailwind v4를 전면 채택하면서 번들 크기를 줄이고 접근성을 강화했는데, 기존 방식과 꽤 많은 게 달라졌습니다.
이 글은 Tailwind와 React를 이미 사용 중인 프론트엔드 개발자를 대상으로 합니다. shadcn/ui와의 차이, Tailwind v4 마이그레이션 시 무엇이 달라지는지, 그리고 실제로 써보면서 마주쳤던 함정들을 솔직하게 정리했습니다.
핵심 개념
핵심 개념들은 서로 독립적으로 보이지만 실제로는 연결돼 있습니다. CSS-First 아키텍처가 OKLCH 색상 토큰을 CSS 변수로 노출하고, Compound Components 패턴은 그 변수들을 활용해 유연한 커스터마이징을 가능하게 합니다. 순서대로 살펴보겠습니다.
CSS-First 아키텍처: tailwind.config.js가 기본값에서 빠진 이유
Tailwind v4의 가장 큰 변화는 설정 방식입니다. 기존에는 tailwind.config.js에 플러그인을 등록하고, theme.extend에 색상과 폰트를 정의했죠. HeroUI v2도 이 방식으로 통합됐습니다.
v3에서는 tailwind.config.js가 권장 방식에서 제외됐습니다. 완전히 사라진 건 아니고 레거시 호환을 위해 여전히 동작하지만, 새 프로젝트에서는 더 이상 필요하지 않습니다. 이제 설정은 전부 globals.css 하나에서 끝납니다.
@import "tailwindcss";
@import "@heroui/styles";
@theme {
--color-primary: oklch(0.6204 0.195 253.83);
--radius: 0.5rem;
}@import로 Tailwind와 HeroUI 스타일을 불러오고, @theme 블록에서 CSS 변수로 테마를 정의하는 구조입니다. 처음엔 "이게 다야?" 싶을 정도로 단순한데, 이게 진짜 강점입니다. 스타일 레이어가 CSS 계층 구조 그대로 드러나니 DevTools에서 어디서 값이 오는지 한눈에 보이거든요. JavaScript 설정 파일을 뒤지던 시간이 꽤 줄었습니다.
참고로 @heroui/styles는 @heroui/react에 기본 포함되지 않는 별도 패키지입니다. 스타일 없이 로직만 쓰는 헤드리스 모드를 지원하기 위해 분리돼 있기 때문에, 설치 시 두 패키지를 함께 추가해야 합니다.
OKLCH 색상 토큰: 다크 모드가 이렇게 쉬웠나 싶습니다
HeroUI v3에서는 모든 디자인 토큰이 oklch() 함수로 정의됩니다.
:root {
--background: oklch(0.9702 0 0);
--foreground: oklch(0.2103 0.0059 285.89);
--accent: oklch(0.6204 0.195 253.83);
--danger: oklch(0.6532 0.2328 25.74);
}
.dark {
--background: oklch(0.15 0.02 285);
--foreground: oklch(0.95 0 0);
--accent: oklch(0.55 0.18 253.83);
}저도 처음엔 이 긴 숫자들이 뭔가 싶었는데, 익숙해지면 오히려 편합니다. oklch(L C H) 구조에서 L은 밝기(01), C는 채도(실용 범위는 대체로 00.4), H는 색조(0~360)입니다.
OKLCH는 CSS Color Level 4에서 도입된 색 공간으로, 사람이 인식하는 밝기·채도·색조를 균일하게 표현합니다. 같은 L 값이면 색상이 달라도 실제로 비슷한 밝기로 보여서, 팔레트 전체의 시각적 균형을 맞추기 쉽습니다. HEX로 다크 모드 색상을 일일이 조정하던 방식과 비교하면, 배경 L 값만 낮춰도 자연스러운 어두운 팔레트가 구성됩니다.
다크 모드 전환도 JavaScript Provider 없이 CSS 선택자만으로 처리됩니다. .dark 클래스나 [data-theme="dark"] 속성을 <html>에 붙이면 끝입니다. SSR 깜빡임(flash of unstyled content) 없이 처리되니 Next.js 환경에서도 군더더기가 없습니다.
Compound Components 패턴: shadcn/ui와 무엇이 다른가
HeroUI v3의 컴포넌트들은 복합 컴포넌트(Compound Components) 패턴을 씁니다. shadcn/ui에서 컴포넌트 소스를 복사한 뒤 내부를 직접 수정하는 방식과 달리, HeroUI는 각 내부 요소를 서브 컴포넌트로 노출해서 조합 방식으로 커스터마이징합니다.
<Modal>
<Modal.Backdrop />
<Modal.Container>
<Modal.Dialog>
<Modal.Header>제목</Modal.Header>
<Modal.Body>본문 내용</Modal.Body>
<Modal.Footer>
<Button slot="close">닫기</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal>이 패턴의 진가는 부분적 커스터마이징에서 드러납니다. Modal.Backdrop만 빼거나, Modal.Header에 className을 직접 붙여도 다른 부분에 영향이 없습니다. shadcn/ui 방식은 소스 파일을 직접 수정하는 완전한 자유를 주지만, 라이브러리 업데이트를 따라가기 어렵다는 트레이드오프가 있습니다. HeroUI의 패턴은 그 중간 어딘가에 있습니다.
Framer Motion이 빠졌습니다 — 체감이 납니다
v2에서는 모든 애니메이션이 Framer Motion에 의존했습니다. 네트워크 탭을 열어보면 Modal 하나 열 때도 Framer Motion 청크가 올라오는 게 보였거든요. v3는 그게 없습니다. 기본 의존성에서 제거하고 네이티브 CSS 트랜지션과 키프레임으로 전환했습니다. GPU 가속은 그대로 받으면서 런타임 JavaScript 부담이 눈에 띄게 줄었습니다.
실전 적용
아래 예시들은 기본 설정에서 출발해 단순 컴포넌트, 복잡한 인터랙션, 테마 응용 순서로 쌓아갑니다. 각 예시가 이전 설정 위에 얹히는 구조이니 순서대로 따라가면 전체 그림이 잡힙니다.
예시 1: 프로젝트 초기 설정
설치는 패키지 두 개면 됩니다. @heroui/react는 컴포넌트 로직을, @heroui/styles는 스타일을 담당해서 분리돼 있습니다.
pnpm add @heroui/react @heroui/styles이어서 globals.css에 import를 추가합니다.
@import "tailwindcss";
@import "@heroui/styles";
@theme {
--color-primary: oklch(0.6204 0.195 253.83);
--color-secondary: oklch(0.5312 0.1784 327.72);
--radius: 0.5rem;
}그리고 Next.js 기준으로 layout.tsx 루트에 Provider를 감쌉니다.
import { HeroUIProvider } from "@heroui/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<HeroUIProvider>{children}</HeroUIProvider>
</body>
</html>
);
}| 파일 | 역할 |
|---|---|
globals.css |
Tailwind + HeroUI 스타일 import, 테마 변수 정의 |
layout.tsx |
HeroUIProvider로 앱 감싸기 |
tailwind.config.js |
v4에서는 더 이상 필요 없음 |
예시 2: Button 컴포넌트 커스터마이징
Button은 가장 자주 쓰는 컴포넌트인 만큼 커스터마이징 시나리오도 다양합니다. HeroUI는 tailwind-merge를 내부적으로 사용하기 때문에, className으로 클래스를 덮어쓸 때 충돌 걱정 없이 쓸 수 있습니다.
import { Button } from "@heroui/react";
// 기본 variant 사용
<Button color="primary" variant="solid">저장</Button>
<Button color="primary" variant="bordered">취소</Button>
<Button color="danger" variant="light">삭제</Button>
// Tailwind 클래스로 직접 오버라이드
<Button className="bg-accent text-white rounded-xl px-8">
커스텀 버튼
</Button>
// 로딩 상태
<Button isLoading color="primary">
처리 중
</Button>tailwind-merge는 Tailwind 클래스 충돌을 자동으로 해결해주는 유틸리티입니다.
className="bg-red-500 bg-blue-500"처럼 같은 속성의 클래스가 겹칠 때 마지막 것만 적용되도록 처리합니다. HeroUI가 내부적으로 사용하므로className오버라이드가 예상대로 동작합니다.
예시 3: Modal 컴포넌트 — 열기/닫기까지 완성된 예시
Compound Components 패턴의 진가는 실제로 동작하는 코드를 봐야 체감이 됩니다. 상태 관리와 slot="close" 동작까지 포함한 예시입니다.
"use client";
import { useState } from "react";
import { Modal, Button } from "@heroui/react";
export function ConfirmModal() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button color="primary" onPress={() => setIsOpen(true)}>
모달 열기
</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Backdrop />
<Modal.Container>
<Modal.Dialog>
<Modal.Header>확인이 필요합니다</Modal.Header>
<Modal.Body>이 작업을 진행하시겠습니까?</Modal.Body>
<Modal.Footer>
{/* slot="close"는 Modal이 자동으로 onClose를 연결합니다 */}
<Button variant="light" slot="close">취소</Button>
<Button
color="primary"
onPress={() => {
// 작업 처리
setIsOpen(false);
}}
>
확인
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal>
</>
);
}slot="close"를 붙인 Button은 Modal이 자동으로 onClose를 연결해주기 때문에 별도 핸들러가 필요 없습니다. Modal.Header, Modal.Body, Modal.Footer 각각에 독립적으로 className을 붙여도 다른 부분에 전혀 영향이 없다는 것도 이 패턴의 핵심입니다.
예시 4: 대용량 데이터 테이블에 가상화 적용
수천 개 행을 렌더링할 때 DOM에 전부 올리면 성능이 급격히 떨어집니다. 실무에서 자주 맞닥뜨리는 상황인데, 기존에는 react-window나 TanStack Virtual을 별도로 붙여야 했죠. HeroUI Table의 isVirtualized prop 하나로 뷰포트에 보이는 행만 렌더링하는 가상화를 켤 수 있습니다.
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@heroui/react";
type User = {
id: number;
name: string;
email: string;
role: string;
};
const columns = [
{ key: "name", label: "이름" },
{ key: "email", label: "이메일" },
{ key: "role", label: "역할" },
];
export function UserTable({ users }: { users: User[] }) {
return (
<Table isVirtualized aria-label="사용자 목록">
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.key}>{column.label}</TableColumn>
)}
</TableHeader>
<TableBody items={users}>
{(user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}라이브러리 안에서 처리해주니 의존성 하나를 덜 관리해도 됩니다.
예시 5: 다크 모드 테마 전환 — Next.js SSR 환경에서 안전하게
CSS 변수 정의는 앞서 본 방식 그대로입니다. 문제는 토글 버튼인데, document를 직접 건드리면 Next.js SSR 환경에서 document is not defined 에러가 납니다. useEffect 안에서 처리하거나 next-themes 라이브러리와 조합하는 게 안전합니다.
/* globals.css */
:root {
--background: oklch(0.9702 0 0);
--foreground: oklch(0.2103 0.0059 285.89);
--accent: oklch(0.6204 0.195 253.83);
}
.dark {
--background: oklch(0.15 0.02 285);
--foreground: oklch(0.95 0 0);
--accent: oklch(0.55 0.18 253.83);
}"use client";
import { useEffect, useState } from "react";
import { Button } from "@heroui/react";
export function DarkModeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// SSR 완료 후에만 실행됩니다
const html = document.documentElement;
if (isDark) {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}, [isDark]);
return (
<Button variant="light" onPress={() => setIsDark((prev) => !prev)}>
{isDark ? "라이트 모드" : "다크 모드"}
</Button>
);
}실제 프로젝트에서는 next-themes와 조합하는 편이 더 편합니다. 시스템 테마 감지, localStorage 유지, 초기 플래시 방지까지 한 번에 처리해주거든요.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 번들 경량화 | Framer Motion 기본 의존성 제거로 런타임 JS 애니메이션 부담 감소 |
| 접근성 내장 | React Aria Components 기반, 키보드 탐색·스크린리더 자동 처리 |
| 서버 컴포넌트 호환 | React Server Components 지원, shadcn/ui 대비 강점 |
| 다크 모드 간소화 | JS Provider 불필요, 순수 CSS 변수 전환으로 SSR 깜빡임 없음 |
| AI 도구 친화 | MCP Server 제공으로 Claude Code·Cursor와 통합 용이 |
| 디자인 완성도 | 세련된 기본 스타일, 별도 디자인 시스템 구축 비용 절감 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 패키지 의존성 | shadcn/ui처럼 소스 복사 방식이 아니라 npm 패키지에 묶임 | 장기 프로젝트라면 라이브러리 유지보수 상태를 주기적으로 확인 |
| v2 → v3 파괴적 변경 | primary-50 등 번호형 컬러 스케일 삭제, 플러그인 방식 폐기 |
마이그레이션 전 공식 마이그레이션 가이드 숙지 필수 |
| Tailwind v4 전제 | Tailwind v3 이하와 호환 불가 | 기존 프로젝트 도입 시 Tailwind 버전 업그레이드 병행 필요 |
| OKLCH 학습 곡선 | 색 공간 개념과 CSS 변수 레이어 이해 필요 | figma-to-oklch 변환 도구, 공식 테마 프리셋으로 시작 권장 |
| 생태계 규모 | shadcn/ui, MUI 대비 커뮤니티·예제 수 적음 | 공식 문서와 GitHub Discussions 적극 활용 |
실무에서 가장 흔한 실수
-
tailwind.config.js에 HeroUI 플러그인을 그대로 두는 경우 — v3는 CSS 파일 import 방식으로 전환됐으므로, 기존 플러그인 설정이 남아 있으면 충돌이 발생합니다.globals.css의@import "@heroui/styles"로 완전히 대체해야 합니다. -
className오버라이드가 작동 안 한다고 포기하는 경우 — HeroUI는tailwind-merge기반으로 클래스 충돌을 처리합니다. 단순className추가가 아니라 충돌하는 클래스를 명확히 덮어써야 적용됩니다.!important대신 더 구체적인 Tailwind 클래스를 먼저 시도해보는 게 좋습니다. -
다크 모드 전환 시 HEX 색상을 직접 쓰는 경우 — OKLCH 변수 체계를 무시하고 컴포넌트마다 HEX 색상을 직접 박으면 다크 모드 전환 시 의도와 다른 색이 나옵니다. CSS 변수를 통해 색상을 정의하고
var(--color-primary)방식으로 참조하는 패턴을 유지하는 게 좋습니다.
마치며
HeroUI v3 + Tailwind v4 조합의 핵심은 CSS 변수 중심의 단순한 아키텍처로, 접근성·다크 모드·번들 크기 세 가지를 동시에 챙길 수 있다는 점입니다. 처음엔 tailwind.config.js가 기본값에서 빠지는 게 낯설지만, 막상 써보면 설정이 줄어들고 스타일 레이어가 명확해지는 경험을 하게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
새 Next.js 프로젝트에서 먼저 실험해보는 것을 추천합니다.
pnpm create next-app으로 프로젝트를 만든 뒤pnpm add @heroui/react @heroui/styles로 설치하고,globals.css에@import "tailwindcss";와@import "@heroui/styles";두 줄을 추가하면 기본 설정이 완료됩니다. -
Button과 Modal 컴포넌트로 Compound Pattern에 익숙해져 보면 됩니다. 간단한 CRUD 화면 하나를 HeroUI로 구성해보면, 각 서브 컴포넌트를 분리해서 스타일링하는 방식이 어떻게 다른지 체감할 수 있습니다.
-
:root의 OKLCH 변수를 직접 수정해보는 것도 권장합니다.--color-primary값을 바꿔보면서 라이트/다크 모드가 CSS 변수만으로 어떻게 동기화되는지 확인해보면 이 아키텍처의 동작 방식이 빠르게 잡힙니다.
참고 자료
- HeroUI v3 공식 문서 — Introduction
- HeroUI v3 릴리즈 노트 — v3.0.0
- HeroUI v3 Theming 공식 문서
- Tailwind v4 마이그레이션 가이드 | beta.heroui.com
- HeroUI v2.8.0 블로그 포스트 — Tailwind v4 첫 지원
- HeroUI v3 완전 가이드 — BSWEN Blog
- Tailwind v4 마이그레이션 with HeroUI — Medium
- GitHub Discussion — HeroUI v3 로드맵
- tailwind-variants GitHub
- HeroUI vs shadcn/ui 비교 — thesys.dev