0% прочитано

CSS Stacking Contexts у Vue-адмінці: чому великий z-index не підняв Media Explorer над Content Blocks

У редакторі ICanUp Media Explorer відкривався поверх Content Blocks, але частина елементів редактора все одно могла опинятися над модальним шаром. Показую, чому великий z-index не завжди вирішує таку проблему, як працюють CSS stacking contexts і чому для overlay потрібен чіткий контракт шарів, а не випадкові числа.

19 вересня 2026 р. 11 хв читанняCSS

У редакторі ICanUp я відкрила Media Explorer із Content Block, щоб вибрати зображення, і побачила дивну картину: модальне вікно вже відкрите, але частина елементів редактора все одно може малюватися поверх нього.

Проблема виглядала як звичайний конфлікт z-index.

Перша реакція в такій ситуації дуже передбачувана: просто поставити модальному вікну ще більше число.

Але CSS працює не як один глобальний список шарів. І саме це робить такі баги значно цікавішими.

Елемент із z-index: 999999 не обов’язково буде вище за елемент із z-index: 10, якщо вони належать до різних stacking contexts.
CSS stacking context diagram showing Media Explorer overlay trapped inside one stacking context while Content Blocks controls render in another higher context
Media Explorer і Content Blocks можуть належати до різних контекстів накладання. Порівнюється не лише z-index самого елемента, а й положення всього його stacking context відносно сусідніх контекстів.

Симптом виглядав як неправильний z-index

Під час відкритого Media Explorer верхнім інтерактивним шаром має бути саме він.

Під ним не повинні з’являтися:

  • контейнери Content Blocks;
  • заголовки та drag handles блоків;
  • кнопки дій;
  • sticky controls редактора;
  • dropdowns і toolbars сторінки позаду;
  • будь-які інші елементи, що належать фоновому редактору.

Коли хоча б один із цих елементів опиняється над модальним вікном, проблема вже не косметична.

Порушується сама ієрархія взаємодії: користувач бачить overlay, але фон візуально продовжує конкурувати з ним.

Чому z-index не є глобальним рейтингом елементів

Найважливіше правило тут таке: z-index порівнюється всередині конкретної системи накладання.

Якщо два елементи належать до різних stacking contexts, браузер спочатку порівнює самі контексти, а вже потім їхніх дітей.

HTML - Два незалежні фрагменти інтерфейсу
<div class="editor">    <div class="content-block">        <button class="block-action">Edit</button>    </div></div> <div class="overlay-host">    <div class="media-explorer">        ...    </div></div>

Якщо .editor і .overlay-host опинилися в різних stacking contexts, величезний z-index на .media-explorer може залишатися замкненим усередині контексту свого батька.

Уявімо два окремі контексти накладання

CSS - Великий z-index усередині нижчого контексту
.editor {    position: relative;    z-index: 20;} .overlay-host {    position: relative;    z-index: 10;} .media-explorer {    position: fixed;    z-index: 999999;}

Інтуїтивно здається, що 999999 має перемогти 20.

Але якщо .overlay-host вже сформував власний stacking context із рівнем 10, його дочірній Media Explorer не може просто перестрибнути контекст .editor із рівнем 20.

999999 залишається всередині свого контексту
root stacking context ├── editor context          z-index: 20│   └── Content Block│└── overlay-host context    z-index: 10    └── Media Explorer      z-index: 999999

Дочірній елемент може бути найвищим у своєму stacking context і все одно залишатися нижче сусіднього контексту.

Stacking context створює не тільки z-index

Через це під час діагностики недостатньо шукати лише z-index.

Новий stacking context можуть створювати різні CSS-властивості.

Властивість або умова

Чому важлива

position + z-index

Класичний спосіб створити окремий контекст

position: fixed

Може створити власний stacking context

position: sticky

Також бере участь у власній системі накладання

transform

Навіть transform без візуально помітного переміщення може створити контекст

opacity менше 1

Ізолює нащадків у новому контексті

filter

Також може створити stacking context

isolation: isolate

Створює контекст навмисно

transform особливо легко пропустити

У складній адмінці transform може з’явитися не через анімацію всього модального вікна.

Його можуть додати drag & drop, transition, component library або невеликий ефект:

CSS - Непомітний transform теж має наслідки
.editor-section {    transform: translateZ(0);}

На екрані такий елемент може виглядати абсолютно звичайно, але для браузера він уже створив нову межу накладання.

Тому під час пошуку причини я дивлюся не тільки на сам Media Explorer, а й на весь ланцюжок його предків.

Потрібно перевіряти весь ancestor chain

Перевірка предків overlay
Media Explorer    |    vModal wrapper    |    vEditor component    |    vLayout container    |    vAdmin root

Будь-який елемент у цьому ланцюжку може створити stacking context і обмежити всі дочірні z-index.

Те саме потрібно перевірити для елемента, який несподівано опинився зверху.

DevTools корисніший за випадкове збільшення числа

Замість циклу 1000 -> 10000 -> 999999 я хочу спочатку відповісти на кілька конкретних питань.

  • Який елемент фактично перекриває overlay?
  • До якого stacking context він належить?
  • До якого stacking context належить Media Explorer?
  • Який ancestor створив кожен із цих контекстів?
  • Які computed styles створюють цю межу?
  • Чи справді overlay знаходиться достатньо високо в DOM?

У браузерних DevTools для цього корисно перевіряти computed values для position, z-index, transform, opacity, filter та isolation.

position: fixed теж не завжди означає viewport

Є ще одна пастка.

Зазвичай position: fixed асоціюється з viewport, тому здається природним використовувати його для модального вікна.

Але певні властивості на ancestor, зокрема transform, можуть змінити containing block для fixed-нащадка.

CSS - Fixed overlay усередині transformed ancestor
.admin-content {    transform: translate3d(0, 0, 0);} .media-explorer {    position: fixed;    inset: 0;}

Тому просто побачити position: fixed недостатньо. Потрібно дивитися, де саме елемент знаходиться в DOM і які властивості мають його предки.

Backdrop і dialog повинні належати одному шару

Media Explorer складається не лише з вікна.

Є щонайменше два елементи:

Структура модального шару
Overlay├── Backdrop└── Dialog

Якщо backdrop і dialog живуть у різних випадкових рівнях, можна отримати ще дивнішу поведінку: фон затемнений, але кнопка Content Block знову з’являється поверх затемнення або навіть поверх самого dialog.

Для мене вони повинні утворювати один узгоджений top-level overlay stack.

Глобальний overlay краще не вкладати глибоко в редактор

Один із найнадійніших архітектурних підходів для складних модальних компонентів у Vue полягає в тому, щоб рендерити їх ближче до кореня застосунку, а не залишати всередині компонента, який їх відкрив.

HTML - Окремий top-level host для overlays
<body>    <div id="app">        <!-- editor -->    </div>     <div id="overlay-root">        <!-- global overlays -->    </div></body>

У Vue для цього часто підходить Teleport.

HTML - Винести Media Explorer із локального DOM-контексту
<Teleport to="body">    <MediaExplorer        v-if="isOpen"    /></Teleport>
Teleport не є магічним виправленням кожного stacking bug. Але він допомагає прибрати overlay з випадкового локального stacking context компонента, який його відкрив.

Я не хочу розкидати z-index по кожному Content Block

Найгірший напрям після такого бага виглядає приблизно так:

CSS - Гонка довільних z-index
.block-card {    z-index: 20;} .block-actions {    z-index: 100;} .editor-toolbar {    z-index: 500;} .media-explorer {    z-index: 999999;}

Так система поступово перетворюється на набір чисел, значення яких уже ніхто не розуміє.

Наступний dropdown отримує 1000000, наступне модальне вікно ще більше число, а початкова причина залишається.

Замість цього потрібен контракт шарів

Я хочу, щоб рівні інтерфейсу мали зрозуміле призначення.

CSS - Приклад системи шарів
:root {    --z-base: 0;    --z-sticky: 100;    --z-dropdown: 200;    --z-overlay: 1000;    --z-modal: 1100;    --z-toast: 1200;}

Конкретні числа тут менш важливі за сам контракт.

Важливо, щоб усі компоненти розуміли:

  • звичайний контент живе на базовому рівні;
  • sticky controls можуть бути вище контенту, але нижче глобального overlay;
  • dropdown має визначений рівень;
  • modal і backdrop мають глобальний верхній рівень;
  • toast може бути окремим найвищим службовим шаром.

Media Explorer має бути top interactive layer

Для Media Explorer контракт особливо простий.

Поки він відкритий, фон не повинен конкурувати з ним ні візуально, ні інтерактивно.

Очікувана ієрархія взаємодії
Media Explorer open Interactive:- Media Explorer dialog- Explorer folders- Explorer files- Explorer internal dropdowns- selection controls Not interactive:- Content Blocks- editor actions- sticky page controls- page dropdowns behind the overlay

pointer-events не виправляє неправильний stacking order

Можна тимчасово заборонити взаємодію з фоном через pointer-events, але це вирішує іншу задачу.

CSS - Блокування взаємодії з фоном
.editor[aria-hidden="true"] {    pointer-events: none;}

Якщо елемент візуально продовжує малюватися над modal, pointer-events: none не виправляє layering.

Він лише робить неправильний шар неклікабельним.

Overflow може створити інший клас проблем

Не кожен візуальний баг модального вікна є stacking context.

Іноді проблема виглядає схоже, але overlay просто обрізається ancestor із overflow.

CSS - Ancestor може обрізати вкладений overlay
.editor-panel {    overflow: hidden;}

Тому під час діагностики я окремо перевіряю дві речі:

Симптом

Ймовірний напрям перевірки

Інший елемент малюється поверх modal

stacking context / z-index

Частина modal фізично обрізана

overflow / containing block

Fixed overlay рухається разом із контейнером

transform / containing block

Фон клікається через overlay

pointer-events / backdrop coverage

Баг потрібно перевірити не тільки в Post editor

Media Explorer у ICanUp є повторно використовуваним компонентом.

Тому виправлення лише одного місця, де він відкрився з Post editor, було б занадто вузьким.

Якщо причина лежить у самому overlay contract, потрібно перевірити всі редактори, які використовують цей компонент.

  • Post editor;
  • Page editor;
  • News editor;
  • інші місця з Content Blocks;
  • мобільний і планшетний layout.

Внутрішні dropdowns Media Explorer теж повинні залишитися правильними

Після підняття самого Media Explorer не можна випадково зламати його внутрішні шари.

Наприклад, dropdown усередині dialog має бути вище його контенту, але все одно належати до загальної модальної ієрархії.

Внутрішні шари залишаються всередині modal contract
Global modal layer└── Media Explorer    ├── backdrop    ├── dialog    └── internal dropdown

Що я перевіряю після виправлення

  • Жоден Content Block не малюється поверх Media Explorer.
  • Drag handles не виходять над backdrop.
  • Sticky controls залишаються під overlay.
  • Backdrop закриває весь потрібний viewport адмінки.
  • Під час прокрутки порядок шарів не змінюється.
  • Відкриття папок не викликає layer jumping.
  • Preview і selection controls працюють поверх dialog.
  • Dropdowns усередині Media Explorer не ховаються за самим dialog.
  • Закриття overlay повертає звичайну взаємодію з редактором.
  • Той самий контракт працює в інших редакторах.
  • Мобільний layout не створює окрему проблему накладання.

Автоматичний тест тут не замінить візуальну перевірку

CSS layering складно повністю довести звичайним unit test.

Але тест усе одно може захистити важливу частину контракту: Media Explorer рендериться в очікуваному overlay host, modal отримує правильний layer token, а редактор не створює випадковий локальний modal wrapper.

Після цього я все одно перевіряю реальне відображення в браузері.

Що я не роблю після такого бага

  • Не збільшую z-index навмання, не перевіривши stacking contexts.
  • Не вважаю z-index глобальним рейтингом усіх елементів сторінки.
  • Не перевіряю тільки сам modal і не ігнорую його ancestors.
  • Не забуваю про transform, opacity, filter та isolation.
  • Не розкидаю випадкові z-index по кожному Content Block.
  • Не використовую pointer-events як заміну правильному layering.
  • Не виправляю reused Media Explorer лише в одному editor.
  • Не забуваю окремо перевірити backdrop, dialog та внутрішні dropdowns.

Мій короткий checklist для stacking context bugs

  • Знайти конкретний елемент, який перекриває overlay.
  • Визначити stacking context обох елементів.
  • Перевірити ancestor chain.
  • Переглянути position і z-index.
  • Перевірити transform.
  • Перевірити opacity.
  • Перевірити filter.
  • Перевірити isolation.
  • Перевірити overflow і containing block.
  • Для глобального overlay розглянути top-level host або Vue Teleport.
  • Використовувати зрозумілий layer contract замість випадкових чисел.
  • Перевірити всі місця повторного використання компонента.

Великий z-index не робить елемент глобально верхнім. Спочатку потрібно зрозуміти, у якому stacking context він взагалі живе.

Висновок

Баг із Media Explorer у ICanUp виглядав як проста задача про z-index, але насправді це задача про структуру CSS-шарів.

Якщо overlay знаходиться всередині локального stacking context, величезне число на самому dialog не гарантує, що він опиниться вище за сусідній контекст редактора.

Тому я дивлюся не тільки на z-index, а на весь ancestor chain, transform, position, opacity, filter, isolation, overflow та фактичне місце overlay у DOM.

Для повторно використовуваного Media Explorer правильнішим напрямом є не гонка чисел, а чіткий top-level overlay contract, де backdrop і dialog гарантовано знаходяться вище за весь редактор.

Для мене головний урок тут простий: коли z-index не працює так, як очікується, потрібно перестати збільшувати число і почати шукати межу stacking context.