Bạn đặt z-index: 999999. Tooltip vẫn nằm sau cái header. Modal vẫn bị một cái card đè lên. Dropdown thì không bị đè, nhưng lại bị cắt mất nửa dưới.
Đây là một trong những kiểu bug CSS rất dễ làm mình mất bình tĩnh, vì phản xạ đầu tiên thường là tăng số lên. 999, không được. 9999, vẫn không. 2147483647, nghe rất quyết tâm nhưng layout vẫn tỉnh bơ như chưa có gì xảy ra.
Vấn đề là z-index không phải bảng xếp hạng toàn trang. Nó chỉ có ý nghĩa trong một số điều kiện nhất định, và đôi khi có những cơ chế khác của browser được ưu tiên hơn hẳn.
1. z-index không tự chạy nếu phần tử không ở đúng trạng thái
Trường hợp cơ bản nhất: bạn gán z-index, nhưng phần tử không thuộc nhóm mà z-index có hiệu lực.
<div class="box-a">A</div>
<div class="box-b">B</div>
.box-a {
z-index: 10;
}
.box-b {
margin-top: -40px;
}
Trong nhiều trường hợp, z-index trên .box-a không giúp gì cả, vì phần tử đang ở trạng thái position: static mặc định. Cách sửa tối thiểu:
.box-a {
position: relative;
z-index: 10;
}
Với Flexbox và Grid, item con cũng có thể nhận z-index mà không cần position, nhưng nếu đang debug một layout bình thường, cứ kiểm tra position trước. Đây là cái bẫy rẻ tiền nhưng rất hay ăn thời gian.
2. Stacking context là cái hộp vô hình nhốt z-index
Điểm quan trọng nhất: z-index chỉ so sánh với các phần tử trong cùng stacking context. Nếu một phần tử bị nhốt trong stacking context thấp hơn, con của nó có z-index cao đến mấy cũng không vượt ra ngoài được.
Ví dụ:
<div class="panel">
<div class="tooltip">Tooltip</div>
</div>
<div class="header">Header</div>
.panel {
position: relative;
z-index: 1;
}
.tooltip {
position: absolute;
z-index: 9999;
}
.header {
position: relative;
z-index: 2;
}
Nhìn qua thì .tooltip có z-index: 9999, còn .header chỉ có z-index: 2. Nhưng .tooltip nằm trong .panel, mà .panel chỉ đứng ở level 1. Browser so .panel với .header trước. Vì 1 < 2, toàn bộ .panel, bao gồm tooltip bên trong, vẫn nằm dưới header.
Nói cách khác: con nhà số 1 không thể tự nhảy lên khu nhà số 2 chỉ vì nó ghi số 9999 trên áo.
Những property rất hay tạo stacking context mới gồm:
positionvớiz-indexkhácautoposition: fixedhoặcposition: stickyopacitynhỏ hơn1transform,scale,rotate,translatefilter,backdrop-filter,perspectiveisolation: isolatecontain: layout,contain: paint,contain: strict,contain: contentwill-changetrỏ tới property có thể tạo stacking contextmix-blend-modekhácnormalcontainer-type: sizehoặcinline-size
Không cần học thuộc lòng hết. Khi một overlay không nổi lên được, hãy đi ngược lên các ancestor và tìm những property kiểu này. Thường thủ phạm nằm ở một wrapper rất vô tội như:
.page {
transform: translateZ(0);
}
Dòng này đôi khi được thêm để “tối ưu animation”, nhưng nó cũng tạo stacking context mới. Và từ đó, một tooltip có thể bắt đầu sống cuộc đời bị nhốt.
3. overflow không quan tâm bạn có z-index bao nhiêu
Một case khác rất dễ nhầm: phần tử không bị đè, nó bị cắt.
<div class="card">
<button>Open menu</button>
<div class="menu">Menu content</div>
</div>
.card {
position: relative;
overflow: hidden;
}
.menu {
position: absolute;
top: 100%;
z-index: 9999;
}
Menu có thể đang ở layer cao hơn thật, nhưng .card nói rằng mọi thứ tràn ra ngoài nó phải bị cắt. z-index không phải kéo cắt giấy. Nó không thể làm phần tử thoát khỏi clipping của ancestor.
Những property cần kiểm tra:
overflow: hidden;
overflow: clip;
overflow: auto;
clip-path: inset(0);
mask-image: linear-gradient(#000, transparent);
contain: paint;
Với dropdown, tooltip, popover, context menu, giải pháp thường là render nó ra gần body hơn, thay vì để nó nằm sâu trong một card có overflow: hidden. Trong React/Vue, đây là lý do người ta hay dùng portal/teleport cho overlay.
4. position: fixed không phải lúc nào cũng fixed theo viewport
Nhiều người nghĩ position: fixed luôn bám viewport. Thường thì đúng. Nhưng nếu ancestor có transform, filter, perspective hoặc một vài dạng contain, fixed element có thể bị neo theo ancestor đó.
<div class="app-shell">
<div class="modal">Modal</div>
</div>
.app-shell {
transform: translateZ(0);
}
.modal {
position: fixed;
inset: 0;
z-index: 9999;
}
Modal nhìn như phải phủ toàn màn hình, nhưng browser có thể xem .app-shell là containing block cho fixed element. Nếu .app-shell nhỏ hơn viewport, bị scroll, hoặc nằm trong một stacking context thấp, modal cũng bị kéo vào rắc rối đó.
Cách debug là tạm tắt các property nghi ngờ trên ancestor:
.app-shell {
/* transform: translateZ(0); */
}
Nếu modal đột nhiên đúng lại, bạn đã tìm được biên giới đang nhốt nó. Cách sửa sạch hơn thường là đưa modal ra ngoài .app-shell, render gần body, hoặc bỏ transform không cần thiết.
5. Top layer thắng z-index
HTML có một khái niệm tên là top layer. Những thứ như <dialog> khi showModal(), Popover API, hoặc fullscreen element có thể được browser đưa lên một lớp đặc biệt phía trên layout bình thường.
<div class="custom-modal">Custom modal</div>
<dialog id="native-dialog">Native dialog</dialog>
.custom-modal {
position: fixed;
inset: 0;
z-index: 999999;
}
document.getElementById("native-dialog").showModal();
Trong tình huống này, custom modal của bạn có z-index rất cao vẫn có thể nằm dưới native dialog. Không phải vì CSS sai, mà vì top layer là một cơ chế riêng của browser. Nó được thiết kế để đảm bảo những UI quan trọng như dialog modal, popover hoặc fullscreen được đặt đúng ưu tiên.
Vì vậy nếu app trộn custom overlay với <dialog> hoặc popover native, đừng chỉ nhìn z-index. Hãy kiểm tra xem có element nào đang ở top layer không.
6. DOM order vẫn quan trọng khi z-index bằng nhau
Khi các phần tử nằm trong cùng stacking context và z-index tương đương nhau, thứ tự xuất hiện trong DOM có thể quyết định phần tử nào nằm trên.
<div class="box box-a">A</div>
<div class="box box-b">B</div>
.box {
position: relative;
z-index: auto;
}
.box-a {
margin-bottom: -40px;
}
Nếu hai phần tử chồng lên nhau mà không có z-index rõ ràng, phần tử xuất hiện sau thường được paint sau, nên nhìn như nằm trên. Đây là lý do đôi khi chỉ đổi thứ tự render trong component là bug biến mất, dù CSS không đổi dòng nào.
Nhưng đừng lạm dụng DOM order để điều khiển layer. Với UI có overlay, hãy có hệ thống layer rõ ràng:
:root {
--z-header: 100;
--z-dropdown: 200;
--z-popover: 300;
--z-modal: 400;
--z-toast: 500;
}
Số không cần quá lớn. Cái cần là thứ bậc nhất quán.
7. Có lúc không phải lỗi z-index, mà là lỗi tương tác
Một phần tử có thể nhìn thấy trên màn hình nhưng click không ăn. Hoặc click vào overlay lại tác động vào phần tử phía sau. Rất dễ kết luận là z-index sai, nhưng thủ phạm có thể là pointer-events, focus, event propagation hoặc JS.
Ví dụ:
.overlay {
position: fixed;
inset: 0;
z-index: 400;
pointer-events: none;
}
Overlay nằm trên thật, nhưng pointer-events: none khiến browser bỏ qua nó khi hit testing. Click sẽ xuyên xuống dưới.
Hoặc ngược lại:
.backdrop {
position: fixed;
inset: 0;
z-index: 300;
}
.modal {
position: fixed;
z-index: 400;
}
Nếu JS gắn listener đóng modal vào backdrop nhưng cấu trúc DOM render modal bên trong backdrop, click trong modal có thể bubble lên backdrop và đóng modal. Nhìn ngoài thì giống modal bị layer sai, nhưng thật ra là event flow sai.
Cách kiểm tra nhanh:
- Dùng DevTools inspect đúng element dưới con trỏ.
- Xem computed
pointer-events. - Tạm thêm background trong suốt có màu để thấy box thật sự nằm đâu.
- Log
event.targetvàevent.currentTarget. - Kiểm tra overlay có bị render vào sai container qua portal/teleport không.
8. Checklist debug khi z-index “không nghe lời”
Mỗi khi gặp bug kiểu này, mình thường đi theo thứ tự sau:
- Phần tử có thật sự nhận
z-indexkhông? Nó cóposition, hoặc là flex/grid item hợp lệ không? - Nó đang nằm trong stacking context nào? Ancestor nào có
transform,opacity,filter,contain,isolation,will-change? - Có ancestor nào đang cắt nó bằng
overflow,clip-path,mask, hoặccontain: paintkhông? - Nếu là
position: fixed, có ancestor nào đang biến nó thành fixed theo container không? - Có element nào đang ở top layer không, ví dụ
<dialog>, popover, fullscreen? - Khi
z-indexbằng nhau hoặcauto, DOM order có đang quyết định thứ tự paint không? - Nếu nhìn đúng mà click sai, kiểm tra
pointer-events, focus trap, event propagation và portal.
Điểm mấu chốt là đừng tăng z-index như bấm thang máy liên tục. Nếu số đã cao mà vẫn không lên, gần như chắc chắn vấn đề không nằm ở con số nữa. Hãy tìm cái hộp đang nhốt nó, cái kéo đang cắt nó, hoặc cái layer đặc biệt đang đứng ngoài luật chơi bình thường.
Khi hiểu được mấy ranh giới này, z-index bớt giống phép thuật đen hơn hẳn. Nó vẫn khó chịu, nhưng ít nhất mình biết nên cãi nhau với ai.