CSSپیشرفته

Stacking Context در CSS: چرا z-index: 9999 کار نمی‌کند

ویژگی z-index یک رتبه‌بندیِ سراسری نیست. فقط عنصرهای داخل یک stacking context را با هم مقایسه می‌کند، و ده‌ها ویژگی بی‌صدا context تازه می‌سازند. ترتیب رسم را یاد بگیرید، contextی را که عنصرتان در آن گیر افتاده پیدا کنید و باگ‌های هم‌پوشانی را برای همیشه حل کنید.

لایه‌های یک صفحهٔ وب به‌شکل سه‌بعدی: یک مودال با z-index 9999 که داخل لایه‌ای زیرِ هدر سایت گیر افتاده
در این صفحه

این یکی از رایج‌ترین باگ‌های CSS است. مودال z-index: 9999 دارد. هدر سایت z-index: 10 دارد. و هدر روی مودال رسم می‌شود. 99999 را امتحان می‌کنید، بعد !important را؛ هیچ چیزی عوض نمی‌شود.

مرورگر خراب نیست. z-index رتبه‌بندیِ سراسریِ همهٔ عنصرهای صفحه نیست. فقط عنصرهای داخل یک stacking context را مرتب می‌کند، و مودال شما داخل contextی گیر افتاده که در کل زیرِ هدر قرار دارد. این مقاله توضیح می‌دهد مرورگر چطور تصمیم می‌گیرد چه چیزی رو باشد، چه چیزهایی این contextها را می‌سازند (اغلب تصادفی) و چطور contextی را که باعث باگ شده پیدا و درست کنید. فرض بر این است که با آبشار (آبشار و Specificity در CSS) و موقعیت‌دهی (positioning) راحت هستید.

باگ#

HTML
<header class="site-header">…</header>
<main class="page">
  <div class="modal">…</div>
</main>
CSS
.site-header { position: sticky; top: 0; z-index: 10; }
.page        { position: relative; z-index: 1; }
.modal       { position: fixed; inset: 0; z-index: 9999; }

مودال زیرِ هدر رسم می‌شود. برای فهمیدنِ دلیلش به دو ایده نیاز داریم: ترتیب رسم داخل یک stacking context، و اینکه contextها چطور تودرتو می‌شوند.

ترتیب رسم داخل یک stacking context#

stacking context گروهی از عنصرهاست که مرورگر آن‌ها را با هم و به‌عنوان یک واحد رسم می‌کند. داخل هر کدام، ترتیب رسم از پایین به بالا این است:

  1. پس‌زمینه و حاشیه‌های عنصری که context را می‌سازد
  2. نوادگانِ موقعیت‌دار با z-index منفی (منفی‌ترین اول)
  3. جعبه‌های بلوکیِ معمولی، به ترتیب سند
  4. floatها
  5. محتوای درون‌خطی: متن، تصویرها، inline-blockها
  6. نوادگانِ موقعیت‌دار با z-index: auto یا 0، به ترتیب سند
  7. نوادگانِ موقعیت‌دار با z-index مثبت، از کوچک‌ترین

دو پیامد آدم‌ها را غافلگیر می‌کند. اول، عنصرهای موقعیت‌دار حتی بدون هیچ z-indexی بالای عنصرهای بدون موقعیت رسم می‌شوند: اضافه کردنِ تنهای position: relative می‌تواند یک عنصر را به جلو بیاورد. دوم، z-index فقط روی عنصرهای موقعیت‌دار (relative، absolute، fixed، sticky) و آیتم‌های flex و grid اثر دارد. روی یک بلوکِ ایستای معمولی نادیده گرفته می‌شود.

contextها مثل شماره‌نسخه تودرتو می‌شوند#

قانونِ کلیدی این است: هر stacking context داخل context والدش به‌عنوان یک لایهٔ واحد رسم می‌شود، در جایگاهی که z-index خودش تعیین می‌کند. هر چیزی داخل آن، درون همان لایه کشیده می‌شود؛ پس یک فرزند هرگز نمی‌تواند از جایگاهِ والدش فرار کند، z-index خودش هر قدر هم بالا باشد.

یک مدل ذهنیِ خوب، شماره‌نسخه‌هاست. در مثال ما، context ریشه دو لایه دارد: هدر (10) و .page (1). عدد 9999 مودال فقط داخل .page معنا دارد؛ پس جایگاهِ واقعی‌اش چیزی مثل 1.9999 است:

⁧ساختار DOM⁩ htmlheadermain.pagediv.modal ⁧stacking context ریشه⁩ z-index: 10z-index: 1z-index: 9999 ⁧stacking context جدید⁩ ⁧چه چیزی رو رسم می‌شود⁩ header10 main.page1 div.modal⁧⁦9999⁩ فقط همین‌جا معنا دارد⁩1.9999 ⁧پس‌زمینهٔ ⁦html⁩⁩ ⁧در ریشه ⁦10⁩ بر ⁦1⁩ غلبه می‌کند، پس ⁦header⁩ روی همه‌چیزِ داخلِ ⁦main.page⁩ قرار می‌گیرد⁩
context ریشه هدر را با z-index 10 و main.page را با z-index 1 در خود دارد. z-index 9999 مودال فقط داخل main.page اعمال می‌شود، پس ترتیبِ واقعی‌اش 1.9999 است که از 10 کمتر است

مرورگر اول در سطحِ ریشه 10 را با 1 مقایسه می‌کند و هدر برنده می‌شود. عدد 9999 اصلاً هرگز با هدر مقایسه نمی‌شود. کلِ باگ همین است.

چه چیزهایی stacking context می‌سازند#

این فهرستِ مهم است، چون بیشترِ stacking contextها تصادفی ساخته می‌شوند؛ به‌عنوان اثرِ جانبیِ ویژگی‌ای که به دلیل دیگری اضافه شده:

ویژگی وقتی context می‌سازد که…
position: relative / absolute z-index هر چیزی جز auto باشد
position: fixed / sticky همیشه
آیتمِ flex یا grid z-index هر چیزی جز auto باشد
opacity کمتر از 1 باشد
transform، scale، rotate، translate هر چیزی جز none باشد
filter، backdrop-filter هر چیزی جز none باشد
clip-path، mask، perspective هر چیزی جز none باشد
mix-blend-mode هر چیزی جز normal باشد
isolation isolate باشد
will-change نامِ یکی از ویژگی‌های بالا را بیاورد
contain layout، paint، strict یا content باشد
container-type size یا inline-size باشد

به‌علاوهٔ عنصر ریشه (<html>) که همیشه یک context است.

پس یک انیمیشنِ fade-in (opacity: 0.99 در طول transition)، یک «ترفند کارایی» مثل transform: translateZ(0) یا یک will-change: transform روی یک wrapper، هر کدام می‌توانند همهٔ z-indexهای داخلشان را گیر بیندازند. علت‌های کلاسیک در دنیای واقعی:

  • یک wrapper انیمیشن‌دار برای صفحه با transform که همهٔ مودال‌ها و منوهای کشویی داخلش را گیر می‌اندازد.
  • یک هدرِ sticky در یک بخش از صفحه و یک منوی کشویی داخلِ بخشِ sticky دیگری.
  • یک کارت با opacity: 0.95 که tooltipاش زیرِ کارتِ بعدی می‌رود.

ویژگی transform موقعیتِ fixed را هم خراب می‌کند

والدی با transform، filter، perspective یا will-change: transform فقط یک stacking context نمی‌سازد: بلوکِ دربرگیرنده (containing block) برای نوادگانِ position: fixed هم می‌شود. آن‌وقت مودالِ «fixed» شما به‌جای viewport نسبت به همان والد جای می‌گیرد و با آن اسکرول می‌شود. اگر یک عنصرِ fixed هم جایش اشتباه است و هم در لایهٔ اشتباه است، دنبال یک transform بگردید.

پیدا کردنِ contextی که گیرتان انداخته#

DevTools مستقیماً stacking contextها را نشان نمی‌دهد، اما با چند خط جاوااسکریپت می‌توانید پیدایشان کنید. عنصرِ مشکل‌دار را در پنل Elements انتخاب کنید و بعد این را در کنسول بچسبانید ($0 همان عنصرِ انتخاب‌شده است):

JavaScript
function stackingContexts(el) {
  const found = [];
  for (let node = el.parentElement; node; node = node.parentElement) {
    const s = getComputedStyle(node);
    const parent = node.parentElement && getComputedStyle(node.parentElement);
    const reasons = [];
    const flexOrGridItem = /flex|grid/.test(parent?.display ?? "");
    if (s.zIndex !== "auto" && (s.position !== "static" || flexOrGridItem))
      reasons.push(`z-index: ${s.zIndex}`);
    if (s.position === "fixed" || s.position === "sticky") reasons.push(`position: ${s.position}`);
    if (s.opacity !== "1") reasons.push(`opacity: ${s.opacity}`);
    for (const prop of ["transform", "scale", "rotate", "translate", "filter", "backdropFilter", "perspective", "clipPath"])
      if (s[prop] && s[prop] !== "none") reasons.push(prop);
    if (s.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${s.mixBlendMode}`);
    if (s.isolation === "isolate") reasons.push("isolation: isolate");
    if (/transform|opacity|filter/.test(s.willChange)) reasons.push(`will-change: ${s.willChange}`);
    if (reasons.length) found.push({ element: node, reasons: reasons.join(", ") });
  }
  return found;
}

console.table(stackingContexts($0));

این تابع از عنصر به بالا حرکت می‌کند و همهٔ والدهایی را که stacking context می‌سازند، همراه با دلیلش، فهرست می‌کند. علت‌های رایج را پوشش می‌دهد، نه تک‌تکِ قاعده‌های جدول را. اولین ردیف همان contextی است که z-index شما در آن گیر افتاده؛ z-index عنصری را که با آن درگیرید با z-index همان والد مقایسه کنید، نه با z-index خودتان.

درست کردنش#

وقتی فهمیدید کدام context عنصر را گیر انداخته، راه‌حلی را انتخاب کنید که با علت جور است:

۱. context تصادفی را حذف کنید. اگر .page واقعاً به z-index: 1 (یا آن transform یا will-change) نیاز ندارد، پاکش کنید. وقتی آن ویژگی «محض احتیاط» اضافه شده، این بهترین راه‌حل است.

۲. عنصر را بیرون ببرید. لایه‌های رویی مثل مودال‌ها، toastها و منوهای کشویی بهتر است فرزندِ مستقیمِ <body> رندر شوند، بیرون از context همهٔ کامپوننت‌ها. بیشترِ فریم‌ورک‌های رابط کاربری ابزاری برای این دارند: createPortal در React، <Teleport> در Vue و کتابخانه‌های portal در Svelte.

۳. از لایهٔ بالایی استفاده کنید. مرورگرهای مدرن یک لایهٔ بالایی (top layer) دارند که بالای کلِ سند و بیرون از همهٔ stacking contextها قرار دارد. عنصرها از طریق APIهای خودِ پلتفرم به آنجا می‌رسند؛ پس اصلاً به z-index نیازی نیست:

HTML
<dialog id="confirm">
  <p>Delete this file?</p>
  <form method="dialog"><button>Cancel</button> <button value="ok">Delete</button></form>
</dialog>

<script>
  document.getElementById("confirm").showModal();   // rendered in the top layer
</script>

<dialog>ی که با showModal() باز شود، عنصرهایی با ویژگیِ popover و عنصرهای تمام‌صفحه، همه در لایهٔ بالایی رندر می‌شوند، هر جای DOM که باشند. showModal() یک ::backdrop هم اضافه می‌کند، فوکوس را داخل دیالوگ نگه می‌دارد و با Esc بسته می‌شود؛ چیزهایی که یک مودالِ دست‌ساز معمولاً اشتباه پیاده می‌کند.

۴. کلِ context را بالا ببرید. اگر هیچ‌کدام از این‌ها جواب نداد، z-index ریشهٔ context (یعنی .page) را عوض کنید، نه عنصرِ داخلش را. یادتان باشد این کار همه‌چیزِ داخلِ .page را هم با خودش جابه‌جا می‌کند.

ویژگی isolation: isolate، نوعِ مفیدِ context#

گاهی می‌خواهید یک stacking context داشته باشید. یک مثالِ رایج، لایه‌ای تزئینی پشتِ محتوای یک کارت است:

CSS
.card { position: relative; background: white; }
.card::before {
  content: "";
  position: absolute;
  inset: -4px;
  z-index: -1;                 /* behind the card's content... */
  background: linear-gradient(135deg, violet, orange);
  border-radius: inherit;
}

بدون stacking context روی .card، شبه‌عنصرِ ::before با z-index: -1 در هر contextی که کارت در آن است، در مرحلهٔ ۲ِ ترتیب رسم کشیده می‌شود: زیرِ پس‌زمینهٔ سفیدِ خودِ کارت. هالهٔ رنگی پشتِ همان کارتی پنهان می‌شود که قرار بود قابش باشد، و حتی ممکن است پشتِ پس‌زمینهٔ والدهای کارت هم برود.

به کارت context خودش را بدهید تا مشکل از بین برود:

CSS
.card { position: relative; background: white; isolation: isolate; }

حالا ::before داخلِ context کارت رسم می‌شود: بالای پس‌زمینهٔ کارت و زیرِ محتوایش. isolation: isolate دقیقاً برای همین وجود دارد. یک context می‌سازد و هیچ کار دیگری نمی‌کند: نه موقعیت‌دهی، نه مقدارِ z-indexی که باید مدیریتش کنید، نه اثر جانبی.

یک مقیاسِ z-index برای بقیه

برای عنصرهایی که واقعاً در یک context مشترک‌اند، از مسابقهٔ عددهای بزرگ‌تر و بزرگ‌تر پرهیز کنید. یک مقیاسِ کوچک را یک بار تعریف کنید، مثلاً --z-dropdown: 10; --z-sticky: 20; --z-overlay: 30; --z-toast: 40;، و فقط از همین مقدارها استفاده کنید. اگر چیزی باید بین دو سطح قرار بگیرد، این یک تصمیمِ طراحی است، نه عددی برای از خود درآوردن.

جمع‌بندی#

  • z-index فقط عنصرهای داخل یک stacking context را مرتب می‌کند؛ contextها تودرتو می‌شوند، پس با شماره‌نسخه فکر کنید: 1.9999 < 10.
  • ترتیب رسم داخل یک context این است: پس‌زمینه، z-index منفی، بلوک‌ها، floatها، محتوای درون‌خطی، z-index: auto/0 و z-index مثبت.
  • z-index فقط روی عنصرهای موقعیت‌دار و آیتم‌های flex/grid کار می‌کند.
  • ویژگی‌های زیادی به‌عنوان اثر جانبی context می‌سازند: opacity کمتر از ۱، transform، filter، will-change، موقعیتِ fixed/sticky و بیشتر.
  • باگ‌های هم‌پوشانی را با حذفِ contextهای تصادفی، بردنِ لایه‌های رویی به <body> یا استفاده از لایهٔ بالایی (<dialog>، popover) حل کنید. وقتی عمداً یک context می‌خواهید، از isolation: isolate استفاده کنید.