Tailwind v4에서는 @theme 안에 --z-overlay: 200을 써도 z-overlay 유틸리티가 만들어지지 않는다. 유틸리티는 네임스페이스에서 값을 읽고, z-가 보는 네임스페이스는 --z-index-다. 에러도 없고 경고도 없다. 클래스가 그냥 존재하지 않고, 요소는 z-index: auto로 떨어지고, 양수 z-index를 가진 건 뭐든 그 위에 그려진다 — 어떤 라우트에서는 그렇고, 어떤 라우트에서는 아니다. 확실하게 결론을 내주는 확인 방법은 소스가 아니라 빌드된 CSS에서 클래스 이름을 grep하는 것이다.
증상
내 booking 템플릿의 모바일 관리자 내비게이션. 햄버거를 탭하면 드로어가 슬라이드로 들어오는데 — 화면을 어둡게 하는 backdrop이 그 위에 얹힌다. 드로어는 스크림 너머로 색이 입혀지고 흐려진 채 보이고, 그 안을 탭할 때마다 이동이 아니라 메뉴가 닫힌다.
데스크톱은 멀쩡했다. md 브레이크포인트 아래에서만 그랬다. build 통과, lint 통과, 테스트 통과.
두 요소는 같은 파일 src/components/admin/AdminSidebar.tsx 안에 수십 줄 간격으로 들어 있다:
// line 221 — the drawer panel
"fixed inset-y-0 start-0 z-overlay w-64 -translate-x-full transition-all duration-200 ease-out",
"md:static md:translate-x-0 md:z-auto",// line 248 — the backdrop
className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"200은 40보다 크다. 드로어가 이겨야 한다.
내가 가정한 것
z-overlay가 200으로 해석된다고 가정했다. 토큰을 src/app/globals.css에 직접 써넣은 게 나였으니까:
@theme inline {
--z-overlay: 200;
}그래서 값은 의심하지 않고, 대신 stacking context를 찾으러 갔다 — 어느 조상 요소의 transform, backdrop-filter, will-change가 새 컨텍스트를 만들어 드로어의 z-index를 그 안에 가둬버린 상황 말이다. "z-index는 맞는데 무시된다"에 대한 흔한 답이고, 거기에 시간을 꽤 태웠다. 스크림에 backdrop-blur-sm이 딱 붙어 있어서 그럴듯해 보였다.
틀린 질문이었다. 드로어의 computed style을 한 번 확인해서 z-index: auto를 봤으면서도, 그걸 "stacking context가 삼키고 있다"로 읽었지 더 단순한 설명 — 적용될 규칙 자체가 없었다 — 은 떠올리지 못했다. z-overlay가 애초에 클래스이긴 한지는 한 번도 확인하지 않았다.
진짜 원인: 테마 네임스페이스
Tailwind v4는 설정을 tailwind.config.js에서 CSS로 옮겼고(@config는 호환용 탈출구로 남아 있다), CSS 변수와 유틸리티를 잇는 다리가 바로 테마 네임스페이스다. 변수 이름의 접두사가 어느 유틸리티 계열에 값을 공급할지 결정한다:
--color-*→bg-*,text-*,border-*,fill-*, …--spacing-*→p-*,m-*,gap-*,w-*, …--radius-*→rounded-*--tracking-*→tracking-*--z-index-*→z-*
이건 설치된 패키지에서 바로 읽어낼 수 있다. node_modules/tailwindcss/dist/lib.mjs(v4.3.2)에서 z 유틸리티는 이렇게 등록돼 있다 — 파일이 minify돼 있어서 줄바꿈은 내가 넣은 것이다:
n("z",{supportsNegative:!0,handleBareValue:({value:a})=>I(a)?a:null,
themeKeys:["--z-index"],handle:a=>[o("z-index",a)],
staticValues:{auto:[o("z-index","auto")]}})themeKeys: ["--z-index"]. 저 배열이 z-*가 들여다볼 변수 접두사의 전체 목록이다. --z-overlay는 거기 없으니 z-overlay는 후보조차 아니다 — 생성할 유틸리티가 없다.
모든 유틸리티에서 같은 필드를 뽑아내면 네임스페이스 목록을 거의 다 얻을 수 있다:
grep -o 'themeKeys:\[[^]]*\]' node_modules/tailwindcss/dist/lib.mjs \
| grep -o -- '--[a-z-]*' | sort -u4.3.2 기준 61개의 네임스페이스. 이걸 정본으로 삼기 전에 주의할 점이 둘 있다: 더 좁은 패턴 themeKeys:\["--[a-z-]*"\]는 키를 하나만 읽는 유틸리티만 잡아서 43개가 나오고, 몇몇 네임스페이스 — 특히 --spacing — 는 별도 코드 경로로 해석돼서 themeKeys 배열에는 아예 나타나지 않는다. 그러니 명세가 아니라 냄새 맡기용 테스트다. 중요한 부분은 어느 쪽이든 그대로다: 목록은 유한하고, 빌드 시점에 정해지고, --z-overlay는 거기에 없다. 접두사가 네임스페이스가 아닌 변수는 유틸리티 엔진 입장에서 그냥 변수일 뿐이다.
브라우저 없이도 컴파일러 API를 직접 써서 그 과정을 눈으로 볼 수 있다:
import { compile } from 'tailwindcss'
const css = `@theme static {
--z-overlay: 200;
--z-index-modal: 300;
}
@tailwind utilities;`
const c = await compile(css, { base: process.cwd() })
console.log(c.build(['z-overlay', 'z-modal']))출력:
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
:root, :host {
--z-overlay: 200;
--z-index-modal: 300;
}
.z-modal {
z-index: var(--z-index-modal);
}변수는 둘 다 나온다. 클래스는 하나만 나온다. 거기서 @theme static을 일부러 썼는데, 세 가지 모드 중 이게 가장 사람을 속이기 때문이다: 변수가 :root에 실리니까 DevTools로 들여다보고 --z-overlay: 200을 확인한 다음, 유틸리티는 존재하지도 않는데 토큰이 멀쩡하다고 결론 내릴 수 있다. 그냥 @theme은 쓰이지 않는 변수를 걷어내고, @theme inline — 내 globals.css가 쓰는 게 이거다 — 은 변수도 클래스도 내보내지 않는다. 모든 모드가 조용하다. 다른 건 가짜 안도감을 얼마나 쥐여주느냐뿐이다.
실패 양상은 "z-index가 없다"보다 나쁘다. z-index: auto인 positioned 요소는 어디로도 올라가지 않고, stacking되지 않은 다른 positioned 요소들과 함께 트리 순서대로 그려진다. 양수 z-index를 가진 요소는 나중 패스에서, 무조건 그 전부보다 위에 그려진다. 그래서 z-40은 둘이 DOM 어디에 있든 망가진 z-overlay를 이긴다 — 그리고 양수 z-index를 선언하는 게 아무것도 없는 라우트에서는, 같은 망가진 클래스가 잘 동작하는 것처럼 보인다. 트리 순서가 마침 유리하게 놓였을 뿐이다. 그래서 이게 클래스 누락이 아니라 라우트마다 다른 렌더링 변덕처럼 읽혔다.
수정
변수 이름 변경, 그게 전부다. src/app/globals.css:
@theme inline {
/* ── Z-index overlay layer ─────────────────────────────────────────── */
/* Tailwind v4 derives the `z-overlay` utility from the --z-index-* namespace,
so this MUST be --z-index-overlay (a plain --z-overlay generates no class). */
--z-index-overlay: 200; /* z-overlay — modals, dropdowns, popovers */
}유틸리티 이름은 z-overlay 그대로다. Tailwind가 토큰 이름에서 네임스페이스 접두사를 떼어내기 때문이다. 17개 컴포넌트에 걸친 22개 호출 지점 — Dialog, Select, Tooltip, DropdownMenu, CommandPalette, Lightbox, LandingHeader — 은 이미 다 맞아서 손댈 게 없었다.
실제로 잡아내는 법
소스를 grep해봐야 아무것도 못 배운다: z-overlay는 해석되든 안 되든 17개 파일에 있다. grep할 대상은 빌드 산출물이다:
pnpm build
grep -o '\.z-overlay{[^}]*}' .next/static/chunks/*.css정상일 때:
.next/static/chunks/2qvhgub5q_n5s.css:.z-overlay{z-index:200}망가졌을 때: 종료 코드 1, 출력 없음. 저 한 줄짜리 명령이 "토큰이 정의돼 있다"와 "클래스가 존재한다"의 차이다. 직접 이름 붙인 유틸리티를 새로 넣을 때마다 돌려보면 된다 — bg-*, rounded-* 등 손으로 토큰화한 무엇에든 그대로 적용된다.
마지막으로 하나, 내가 이걸 한 프로젝트에서만 겪은 이유다. 나머지 네 템플릿은 --z-index-* 토큰을 아예 정의하지 않는다: 셋은 임의 값으로 쌓고 — z-[90], z-[100], z-[250] — 넷째는 평범한 CSS에서 z-index를 손으로 쓴다. 두 경로 모두 테마를 완전히 우회하고 언제나 컴파일된다. 매직 넘버를 이름 붙인 토큰으로 승격시킨 건 booking뿐이고, 네임스페이스 규칙에 걸리는 유일한 길이 그거다. 그 말은 booking에서 컴포넌트를 다른 네 개 중 아무 데로나 복사해 가면 z-index가 조용히 사라진다는 뜻이기도 하다: 클래스 이름은 따라가지만 토큰은 따라가지 않는다.
이건 내가 booking 템플릿에서 출하하는 패턴이고, 라이브 데모는 booking.violettadev.com에 있다.
tailwindcss@4.3.2와 next@16.2.10에서 검증했다.