CSS‑ті модальні вікна, які вас не підведуть: :target, затемнення та шаблони закриття

Було корисно?

Десь у продакшні модальне вікно зараз блокує кнопку оформлення замовлення, затримує користувача клавіатури або перетворює кнопку «Назад» на рулетку. І найгірше: «все працювало в мене», бо на вашій машині не було тієї самої масштабування шрифтів, рівня зуму, особливостей iOS, хеш‑роутінгу або додаткового контексту стеку, створеного нешкідливим на вигляд transform.

CSS‑ті модальні вікна цілком можуть бути придатними в конкретних ситуаціях: маркетингові сторінки, сайти документації, інтерфейси з малою інтерактивністю або «потрібен діалог, але від юридичного не можна відправляти JS»‑ситуації. Але якщо ви вважаєте їх безкоштовним обідом, вони випишуть вам рахунок пізніше — зазвичай у вигляді боргу з доступності та навігаційних країв.

Коли використовувати CSS‑ті модальні вікна (і коли перестати бути сміливим)

CSS‑ті модальні вікна найкраще підходять, коли вміст модального вікна поверхневий, модель стану проста, і сторінка не має клієнтського роутера, який вам проти. Думайте «деталі політики куків», «лайтбокс для зображення», «підписка на розсилку», «фрагмент умов» або «деталі рядка таблиці» на переважно статичній сторінці.

Вони погано підходять, якщо будь‑що з наведеного нижче правда:

  • Вам потрібне реальне управління фокусом (замкнути фокус, відновити фокус), і вам не байдуже до клавіатурних користувачів за межами галочки в таблиці відповідності.
  • У вас є вкладені модальні вікна, багатокрокові потоки або кілька оверлеїв.
  • Вам потрібно надійно закривати по Escape.
  • Ви всередині SPA‑роутера, який володіє хешем, історією та відновленням прокрутки.
  • Вам потрібно надійно заборонити взаємодію з фоном, включно з користувачами скрінрідерів.

Якщо ви будуєте модальне вікно в додатку, яке змінює дані користувача, я буду категоричним: відправляйте JS‑модаль з правильною семантикою діалогу й управлінням фокусом. Наведені нижче CSS‑ті патерни — для ситуацій, де обмеження реальні, а не коли ви намагаєтеся виграти конкурс чистоти.

Цитата, яка витримує перевірку в операціях і надійності UI: перефразована ідея Джона Оллспо (John Allspaw): зробіть систему такою, щоб було легко робити правильно й важко — неправильно. Ваше модальне вікно має бути важким у неправильному використанні — особливо для користувачів, які не підписувалися на вашу хитрість.

Факти й контекст: чому ці патерни існують

  • Селектор :target походить ще з епохи CSS2 і поведінки фрагментів URL: його спочатку створили для навігації в межах сторінки, а не для машин стану UI. Ми переосмислили його, бо він уже був усюди.
  • CSS‑ті оверлеї стали популярні під час хвиль «без JS» і «прогресивного покращення», коли ширина каналу й блокувальники скриптів були поширені. Патерни закріпилися.
  • Елемент HTML <dialog> відносно недавно став звичним у широкому використанні, і роками його не підтримували послідовно, тому команди будували власні модалі (на JS або CSS).
  • backdrop-filter (розмиті фони) з’явився пізно й лишається вимогливим до продуктивності, особливо на мобільних GPU і складних сторінках. Це не «безкоштовна краса».
  • Баги контекстів стеку вибухнули, коли стали звичними position: sticky, transform і filter. Більшість із них можуть створювати нові контексти стеку й роблять вашу стратегію «просто z-index: 9999» недієвою.
  • Фрагменти хешу впливають на історію браузера. Кожне відкриття/закриття через :target може створювати запис в історії, тож кнопка «Назад» стає частиною вашого UI, хочете ви того чи ні.
  • Ранні скрипти lightbox надихнули CSS‑клони: візуальна мова (затемнений фон + центрований блок) стала «стандартом», ще до того як практики доступності наздогнали.
  • Mobile Safari регулярно був джерелом проблем з оверлеями через зміни в розмірах в’юпорту, поведінку адресного рядка й ланцюжок прокрутки. Якщо ваш модал «провалюється тільки на iPhone», це не випадковість; це традиція.

Патерн 1: :target модальне вікно (кероване хешем)

Як це працює

Селектор :target застосовує стилі до елемента, чиє id відповідає фрагменту URL. Клік по посиланню на #modal «активує» модаль, змінюючи фрагмент; клік по # або іншому фрагменту «деактивує» його.

Це звучить занадто просто, бо так і є. Але є й одна важлива перевага: це не залежить від прихованих інпутів і асоціацій label. Це покладається на навігацію, в чому браузери хороші.

Мінімальна, придатна реалізація

cr0x@server:~$ cat modal-target.html
<!-- Trigger -->
<a href="#modal-about" class="btn">About pricing</a>

<!-- Modal container -->
<div id="modal-about" class="modal" aria-hidden="true">
  <a href="#close" class="modal__backdrop" aria-label="Close"></a>

  <div class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="modal-about-title">
    <header class="modal__header">
      <h2 id="modal-about-title">Pricing details</h2>
      <a class="modal__close" href="#close" aria-label="Close dialog">×</a>
    </header>

    <div class="modal__body">
      <p>Short copy. No form submission. No wizard.</p>
    </div>
  </div>
</div>

<style>
.modal {
  position: fixed;
  inset: 0;
  display: none;
  z-index: 1000;
}
.modal:target {
  display: block;
}
.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.55);
}
.modal__dialog {
  position: relative;
  max-width: min(680px, calc(100vw - 2rem));
  margin: 10vh auto;
  background: white;
  color: black;
  border-radius: 12px;
  padding: 1rem 1.25rem;
  box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.modal__close {
  float: right;
  text-decoration: none;
  font-size: 1.5rem;
  line-height: 1;
}
</style>

Переваги :target

  • Немає прихованих інпутів. Менше DOM‑фокусних хитрощів.
  • Стан доступний по лінку. Можна поділитися URL, що відкриває модаль. Інколи це функція, інколи — юридична проблема.
  • Працює без скриптів. Очевидно, але корисно на сторінках з жорсткими обмеженнями.

Що може вкусити

  • Забруднення історії. Кожне відкриття/закриття може створювати записи в історії. Користувачі натискають Back і модаль знову відкривається; ще раз — і сторінка прокручується; тепер вони вас ненавидять.
  • Колізії з роутером. Якщо ваш додаток використовує хеш‑роутинг, #modal-about може виявитися маршрутом, а не станом фрагмента. Насолоджуйтеся інцидентом.
  • Поведінка прокрутки до цілі. Деякі браузери можуть прокрутити до цільового елемента. З position: fixed та inset: 0 зазвичай цього не видно, але не ставте на це всю свою доступність.

Вибір дизайну: використовуйте :target для контент‑модалів на мульти‑сторінкових сайтах. Уникайте всередині SPA, якщо ви не контролюєте роутер і не інтегрували його явно.

Патерн 2: checkbox (:checked) модальне вікно

Як це працює

Ви створюєте прихований чекбокс. Label відкриває його; інший label (або label‑бекдроп) закриває. CSS спостерігає за input:checked і показує модаль. Це машина стану, замаскована під елемент форми.

Реалізація з меншими підводними каменями

cr0x@server:~$ cat modal-checkbox.html
<input id="m1" class="modal-toggle" type="checkbox" />

<label for="m1" class="btn">Open details</label>

<div class="modal" role="dialog" aria-modal="true" aria-labelledby="m1-title">
  <label class="modal__backdrop" for="m1" aria-label="Close"></label>

  <div class="modal__dialog">
    <header class="modal__header">
      <h2 id="m1-title">Details</h2>
      <label class="modal__close" for="m1" aria-label="Close dialog">×</label>
    </header>
    <div class="modal__body">
      <p>Checkbox pattern: no hash, no history entries.</p>
    </div>
  </div>
</div>

<style>
.modal-toggle {
  position: absolute;
  left: -9999px;
}
.modal {
  position: fixed;
  inset: 0;
  display: none;
  z-index: 1000;
}
.modal-toggle:checked ~ .modal {
  display: block;
}
.modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.55);
  cursor: pointer;
}
.modal__dialog {
  position: relative;
  max-width: min(680px, calc(100vw - 2rem));
  margin: 10vh auto;
  background: white;
  border-radius: 12px;
  padding: 1rem 1.25rem;
}
.modal__close {
  cursor: pointer;
  float: right;
  font-size: 1.5rem;
  line-height: 1;
}
</style>

Переваги

  • Немає змін хешу. Кнопка «Назад» залишається кнопкою «Назад».
  • Компонується з роутерами. Це просто стан у DOM.
  • Можливі кілька модалів, якщо тримати ID унікальними й структуру чистою.

Що погано (і це погано)

  • Це семантична брехня. Чекбокс — не діалог. Скрінрідери можуть оголошувати це дивно; ви підлатовуєте семантику ARIA.
  • Обмеження структури DOM. Загальний селектор‑сестра (~) означає, що ваш модал має слідувати за чекбоксом у порядку DOM. Вітаємо, ваша розмітка тепер залежить від CSS‑хитрощі.
  • Фокус все ще не керується. Без JS неможливо надійно замкнути фокус або відновити фокус при закритті.

Принципова думка: якщо ви змушені робити CSS‑ті в середовищі, де хеші токсичні (SPA‑роутинг), техніка з чекбоксом зазвичай є меншим злом. Але документуйте обмеження структури, як API.

Оформлення затемнення, що працює

Затемнення — не декорація; це поверхня керування

У модального бекдропа три завдання:

  1. Позначити модальність. «Ви зараз у діалозі».
  2. Запобігти взаємодії. Блокувати кліки в основному інтерфейсі.
  3. Забезпечити шлях втечі. Клік поза модалем для закриття (коли доречно).

У CSS‑тих модалях бекдроп — ваш основний межовий елемент взаємодії. Якщо він занадто малий, неправильно розміщений або фактично не перехоплює події вказівника, користувачі клікнуть крізь нього й активують сторінку позаду. Це не «дрібна помилка». Це зламана панель керування.

Патерни бекдропа, що не пропускають клики

  • Використовуйте повноекранний позиціонований елемент: position: absolute; inset: 0; всередині фіксованого контейнера модального вікна.
  • Переконайтеся, що він лежить під діалогом, але над сторінкою: бекдроп і діалог повинні бути в одному контексті стеку; не ведіть війну глобальних z‑index.
  • Віддавайте перевагу реальному елементу, а не псевдоелементу, коли його потрібно зробити клікабельним (закриття по кліку). Псевдоелементи можуть бути клікабельними, але простіше думати про реальний елемент.

Розмиті фони: backdrop-filter витратний

Якщо ви застосуєте backdrop-filter: blur(10px), ви просите браузер просамплити й розмити все позаду оверлею. На складній сторінці це може знищити фреймрейт. Тестуйте на слабкому телефоні, а не на вашому 32‑ядерному лэптопі.

cr0x@server:~$ cat backdrop.css
.modal__backdrop {
  background: rgba(0,0,0,0.45);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

Використовуйте розмиття помірковано. Якщо продуктивність є питанням, простий напівпрозорий бекдроп — нудна, але правильна відповідь. Нудне — добре. Нудне доставляється.

Способи закриття: зробіть зрозумілим, зробіть безпечним

Закриття має бути надлишковим

Користувачі мають мати можливість закрити модаль через:

  • Видиму кнопку закриття у верхньому правому куті (або в лівому у деяких RTL контекстах).
  • Клік по бекдропу (для не‑критичних діалогів).
  • Клавіатурну навігацію до елемента закриття (та активація його).

У CSS‑тих патернах у вас не буде надійного закриття по Escape. Не прикидайтеся, що воно є. Якщо модаль настільки критичний, що користувачі інстинктивно натискають Escape, він достатньо критичний, щоб виправдати JS.

Примітки до дизайну кнопки закриття

  • Площа влучання має значення. Зробіть її щонайменше 40×40 CSS‑пікселів. «×» у 12px — це виклик, а не елемент керування.
  • Використовуйте явну підказку. aria-label="Close dialog" — не опція.
  • Розміщуйте її раніше в DOM всередині діалогу, щоб клавіатурні користувачі доходили до неї швидко.

Жарт №1: Єдина річ більш наполеглива, ніж модаль, який не закривається — це людина, яка наполягає, що це «швидкий CSS‑фікс».

Закриття по бекдропу: коли його відключати

Клік поза модалем для закриття зручний для лайтбоксів і інформаційних панелей. Він ризикований для форм, підтверджень або всього, де неправильний клік може втратити введені дані. Для таких випадків зробіть бекдроп інертним (без закриття по кліку) і зробіть закриття явним.

У CSS‑тих умовах відключення закриття по бекдропу зазвичай означає: бекдроп — це <div>, а не посилання/label; він блокує кліки, але не перемикає стан.

Чек‑реалій доступності (без відмовок)

Повністю доступний модал у чистому CSS не реалізуєш

Будемо прямими. Справжній модал потребує:

  • Переміщення фокусу в діалог при відкритті.
  • Замикання фокусу всередині діалогу, поки він відкритий.
  • Відновлення фокусу на відкривачі після закриття.
  • Запобігання навігації підкріпленням скрінрідерами.
  • Закриття по Escape (очікувана поведінка).

CSS сам по собі не може управляти переходами стану фокусу. Ви можете частково наблизити це за допомогою autofocus в обмежених сценаріях, але надійно замкнути фокус без JS не вийде. Отже: якщо ваш модал — ключовий елемент UI, припиніть намагатися робити його тільки на CSS.

Що ви все ще можете й маєте зробити

  • Використовуйте семантичну структуру: role="dialog", aria-modal="true" і aria-labelledby.
  • Ховайте модаль, коли він закритий: display: none видаляє його з дерева доступності. Добре.
  • Переконайтеся, що елементи закриття активуються з клавіатури: посилання (<a>) або кнопки (<button>). Labels можна активувати, але вони менш очевидні для допоміжних технологій.
  • Не використовуйте aria-hidden="true" як перемикач у CSS‑тіх патернах. Ви не можете поміняти його без JS; залишити його «лежати» гірше, ніж опустити взагалі.

Краща альтернатива, якщо можна мінімальний JS: <dialog>

Якщо JS дозволений хоча б частково, використовуйте <dialog> і викликайте showModal(). Воно дає реальний бекдроп і кращу семантику. Вам все одно знадобляться політики фокусу й закриття, але ви починаєте з примітива браузера, а не з трюку.

Z‑index і контексти стеку: тихий вбивця модалів

Чому «z-index: 9999» не працює

Z‑index порівнює елементи лише в межах одного контексту стеку. А контексти стеку створюють речі, які ви додаєте з незалежних причин:

  • transform (навіть translateZ(0))
  • filter, backdrop-filter
  • opacity < 1
  • position + z-index в певних поєднаннях
  • isolation: isolate
  • contain: paint і родичі

Отже ваш модал може мати z‑index 1000000 і все одно рендеритися під липким хедером, що живе в іншому контексті стеку з нижчим локальним z‑index. Це та частина, де люди починають додавати випадкові значення z‑index, поки CSS не нагадуватиме розгін BIOS.

Як уникнути бою

  • Монтуйте модал ближче до кінця <body>. Тримайте його поза компонентним вкладенням, що застосовує трансформи.
  • Зробіть контейнер модального вікна коренем стеку: position: fixed плюс чіткий z-index.
  • Уникайте застосування transform до body або великих обгорток макета, якщо ви також залежите від фіксованих оверлеїв. Це відомий підводний камінь.

Блокування прокрутки й мобільні пастки в’юпорту

CSS‑ті блокування прокрутки — лише часткове рішення

У JS‑модалі ви зазвичай ставите body { overflow: hidden; } під час відкриття. CSS‑ті патерни не можуть глобально переключати це без складних селекторів і уважної структури.

Іноді можна використати :has() (де підтримано), щоб зафіксувати прокрутку, коли модал targeted/checked. Але покладання на :has() для базової поведінки все ще має ризик сумісності в деяких корпоративних середовищах.

cr0x@server:~$ cat scroll-lock.css
/* Only if you can rely on :has() support */
html:has(.modal:target),
html:has(.modal-toggle:checked) {
  overflow: hidden;
}

Без :has() практичний компроміс такий:

  • Зробіть контейнер модального вікна position: fixed; inset: 0;
  • Зробіть тіло діалогу прокручуваним з max-height і overflow: auto
  • Прийміть, що сторінка позаду може все ще скролитися в деяких випадках (особливо iOS rubber‑banding)

Запобігання ланцюженню прокрутки та ефекту overscroll

Коли тіло діалогу досягає меж прокрутки, браузери можуть «ланцюжити» прокрутку до сторінки позаду. Ви можете зменшити це за допомогою:

cr0x@server:~$ cat overscroll.css
.modal__dialog {
  max-height: 80vh;
  overflow: auto;
  overscroll-behavior: contain;
}

Це не вирішить усе на кожному мобільному браузері, але зменшує найгірше відчуття «прокручування сторінки позаду модального вікна».

Швидкий план діагностики

Ви на виклику для UI‑інциденту. Модаль застряг відкритим, не відкривається або блокує кліки. Ось що ви перевіряєте першим, другим, третім — бо блукати DevTools не є стратегією.

1) Перше: модаль взагалі активується?

  • Для :target: чи фрагмент URL збігається з id модального елемента? Якщо ні, селектор ніколи не спрацює.
  • Для checkbox: чи стоїть чекбокс у DOM в стані checked? Якщо ні, CSS нічого не покаже.

2) Друге: він видимий, але за чимось?

  • Перевірте обчислений z-index контейнера модального вікна та чи він у очікуваному контексті стеку.
  • Шукайте батьківські transform/filters, що створюють контексти стеку.

3) Третє: видимий, але неінтерактивний?

  • Перевірте, чи бекдроп перехоплює кліки або пропускає їх далі (pointer‑events, розміри, позиціонування).
  • Перевірте, чи діалог не висунутий за межі екрану через відступи й зміни вьюпорту (адресний рядок на мобільних, зум).

4) Четверте: поведінка навігації та історії

  • Якщо користувачі повідомляють «Назад знову відкриває модаль», ви маєте справу з історією від :target.
  • Якщо маршрут SPA змінюється несподівано при відкритті, хеш належить роутеру.

Типові помилки: симптом → корінь → виправлення

Кліки проходять крізь бекдроп і активують кнопки позаду

Симптоми: користувач клікає поза діалогом; елементи сторінки позаду активуються. Іноді модаль також закривається, іноді ні.

Корінь: бекдроп не покриває весь вьюпорт (inset відсутній), або бекдроп має pointer-events: none, або він лежить під вмістом через контекст стеку.

Виправлення: переконайтеся, що бекдроп позиціонований і розмірований правильно, і що контейнер модального вікна створює передбачуваний контекст стеку.

Модаль відкривається, але частково за екраном на мобільних

Симптоми: верх діалогу прихований під адресним рядком; кнопка закриття недоступна; вміст дивно скролиться.

Корінь: margin: 10vh auto плюс динамічні вьюпорт‑одиниці поводяться по‑іншому; також часто, коли масштаб шрифтів збільшено.

Виправлення: використовуйте max-height і внутрішню прокрутку; розгляньте margin: 2rem auto і вирівнювання через flex.

Модаль відмовляється з’являтись у проді, але працює локально

Симптоми: посилання «Відкрити» змінює URL або чекбокс перемикається, але нічого не з’являється.

Корінь: збірка CSS змінила специфічність/порядок селекторів; пізніше правило переопреділяє display: block; або контейнер модального вікна відсутній через відмінності шаблонів.

Виправлення: перевірте обчислені стилі в продакшн‑зборці; менше покладання на ігри зі специфічністю; розмістіть стилі модального в кадрі компоненту з явними селекторами.

Поведінка кнопки «Назад» здається зламаною

Симптоми: Back закриває модаль, потім Back знову його відкриває; або Back стрибає до дивних позицій прокрутки.

Корінь: :target додає записи в історію і викликає прокрутку‑до‑фрагмента.

Виправлення: використовуйте патерн з чекбоксом або JS‑керовану історію; якщо ви мусите використовувати :target, прийміть, що Back — частина UX і спроєктуйте її.

Липкий хедер перекриває модаль

Симптоми: хедер видно над модалем/бекдропом; користувачі можуть і далі клікати елементи хедера.

Корінь: хедер знаходиться в вищому контексті стеку; модаль загнаний у нижчий через трансформований предок.

Виправлення: перемістіть модаль до кінця body; видаліть трансформи з предків; явно створіть контекст стеку на корені модалю.

Користувачі клавіатури губляться або застрягають

Симптоми: таби доходять до сторінки позаду модалю; фокус починається позаду модалю; закриття повертає фокус у початок сторінки.

Корінь: відсутнє управління фокусом (обмеження CSS).

Виправлення: якщо доступність важлива (а вона важлива), використовуйте JS або <dialog>. Якщо змушені до CSS‑тіх рішень, тримайте вміст мінімальним і забезпечте ранню, видиму кнопку закриття.

Практичні завдання з командами, виводами й рішеннями

Це типи «зроби це зараз» перевірок, які я виконую, коли CSS‑ті модалі поводяться ненормально в різних середовищах. Вони навмисно конкретні. Кожне завдання включає команду, приклад виводу, що це значить, і рішення, яке ви приймаєте.

Завдання 1: Переконатися, що :target дійсно співпадає

cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlparse
u="https://example.test/page.html#modal-about"
p=urlparse(u)
print("fragment:", p.fragment)
PY
fragment: modal-about

Що це значить: браузер буде таргетити id="modal-about".

Рішення: якщо фрагмент не збігається з id модалю точно (регістр важливий), припиніть дебаг CSS і виправте розмітку/лінки.

Завдання 2: Перевірити, що id модалю існує в згенерованому HTML

cr0x@server:~$ grep -R --line-number 'id="modal-about"' dist/
dist/page.html:214:<div id="modal-about" class="modal">

Що це значить: елемент існує у продакшн‑виводі.

Рішення: якщо grep нічого не знайшов, ваш pipeline видалив або перейменував його (шаблони, CMS‑блоки, partials). Виправляйте збірку, не CSS.

Завдання 3: Перевірити, що CSS модалі пережили мініфікацію/бандлінг

cr0x@server:~$ grep -R --line-number '\.modal:target' dist/assets/
dist/assets/app.min.css:1:.modal:target{display:block}

Що це значить: критичний селектор присутній.

Рішення: якщо відсутній, ваш tree‑shaker витер його (поширено з utility‑first наборами). Додайте safelist‑правила або реконструюйте CSS.

Завдання 4: Виявити, що purge/tooling видаляє «невикористовувані» селектори

cr0x@server:~$ rg "content:" tailwind.config.js
18:  content: ["./src/**/*.html","./src/**/*.js"],

Що це значить: purge сканує лише ці файли.

Рішення: якщо HTML модалю інжектиться з CMS або markdown, не охоплених шляхами, purge може видалити стилі модалю. Додайте шляхи або safelist селектори modal.

Завдання 5: Підтвердити порядок DOM чекбоксу й модалю для селектора ~

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("modal-checkbox.html").read()
s=BeautifulSoup(html,"html.parser")
toggle=s.select_one("input.modal-toggle")
modal=s.select_one("div.modal")
print("toggle before modal:", toggle.sourceline < modal.sourceline)
PY
toggle before modal: True

Що це значить: загальний селектор‑сестра може застосуватися.

Рішення: якщо toggle йде після модалю, CSS ніколи не спрацює. Перемістіть toggle раніше або змініть патерн.

Завдання 6: Знайти тригери контексту стеку в CSS

cr0x@server:~$ rg -n "transform:|filter:|backdrop-filter:|opacity:" src/styles/
src/styles/layout.css:44:transform: translateZ(0);
src/styles/header.css:12:position: sticky;
src/styles/header.css:13:z-index: 2000;

Що це значить: присутні трансформи й фільтри; липкі хедери ймовірно створюють шари стеку.

Рішення: якщо модаль вкладений під трансформований контейнер, перемонтуйте корінь модалю або приберіть transform‑хак.

Завдання 7: Перевірити, що бекдроп покриває вьюпорт

cr0x@server:~$ node - <<'NODE'
const css = `
.modal__backdrop { position:absolute; inset:0; }
`;
console.log(css.includes("inset:0") ? "OK: inset present" : "BAD: missing inset");
NODE
OK: inset present

Що це значить: використано найпростіший і найнадійніший розмір.

Рішення: якщо inset відсутній — додайте його; не намагайтеся з «width:100%; height:100%» у вкладених контекстах і потім дивуйтесь, чому це ламається.

Завдання 8: Перевірити випадковий pointer-events: none на оверлеї/бекдропі

cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles/
src/styles/utilities.css:88:.no-pointer{pointer-events:none}

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

Рішення: якщо ваш бекдроп наслідує клас «no-pointer» через композицію компонентів, виправте композицію класів; не виправляйте це додаванням більше z‑index.

Завдання 9: Відтворити проблему з історією при :target

cr0x@server:~$ cat <<'TXT'
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
TXT
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)

Що це значить: це не баг; це дизайн навігації фрагментів.

Рішення: якщо такий UX неприйнятний — припиніть використовувати :target тут.

Завдання 10: Переконатися, що CSP продакшну не блокує інлайн‑стилі (якщо ви їх використовували)

cr0x@server:~$ curl -I https://example.test/page.html | sed -n '1,20p'
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; style-src 'self'

Що це значить: інлайн‑блоки <style> можуть бути заблоковані, якщо 'unsafe-inline' не дозволено.

Рішення: перемістіть CSS модалю в підключений файл або оновіть CSP. Не відправляйте «працює в dev» інлайн CSS у середовище з жорстким CSP.

Завдання 11: Виявити зміщення макета через масштабування шрифтів

cr0x@server:~$ python3 - <<'PY'
base=16
scaled=20
close_btn=24
print("close button px at base:", close_btn)
print("close button px relative to font scaling:", close_btn*(scaled/base))
PY
close button px at base: 24
close button px relative to font scaling: 30.0

Що це значить: масштабування шрифтів впливає на цілі та макет. Якщо ви задали розміри margin через vh, діалог може зміщуватись.

Рішення: задавайте розміри діалогу через max‑width/max‑height і внутрішню прокрутку, не крихкі відступи по вьюпорту.

Завдання 12: Перевірити дублікати id (тихий вбивця :target)

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
ids={}
dups=[]
for el in s.select("[id]"):
  i=el["id"]
  ids[i]=ids.get(i,0)+1
for i,c in ids.items():
  if c>1:
    dups.append((i,c))
print("duplicates:", dups[:10])
PY
duplicates: []

Що це значить: дублікати id не виявлено.

Рішення: якщо дублікати є, :target може влучити в неправильний елемент або поводитись непослідовно. Виправте id; не чіпайте CSS, поки id не унікальні.

Завдання 13: Переконатися, що модаль змонтовано в кінці body (щоб уникнути трансформованих предків)

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
body=s.body
last_tags=[t.name for t in body.find_all(recursive=False)][-5:]
print("last top-level body children:", last_tags)
PY
last top-level body children: ['footer', 'div', 'script', 'script', 'script']

Що це значить: є div близько до кінця body, що може бути вашою кореневою точкою для оверлеїв.

Рішення: монтуйте модалі як топ‑левел‑сиблінги body, а не глибоко всередині трансформованих обгорток макета.

Завдання 14: Оцінити вплив стилів на розмір CSS файлу

cr0x@server:~$ ls -lh dist/assets/app.min.css
-rw-r--r-- 1 www-data www-data 182K Dec 12 09:14 dist/assets/app.min.css

Що це значить: розмір стилів помірний; але це не вимірює вартість рендеру в рантаймі.

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

Завдання 15: Перевірка специфічності селекторів відкриття/закриття модалю

cr0x@server:~$ rg -n "\.modal\s*\{|\:target|\:checked" dist/assets/app.min.css | sed -n '1,20p'
1:.modal{position:fixed;inset:0;display:none;z-index:1000}
1:.modal:target{display:block}
1:.modal-toggle:checked~.modal{display:block}

Що це значить: правила відкриття присутні й прості.

Рішення: якщо ви бачите пізніші правила на кшталт .modal{display:none!important}, приберіть їх або зробіть скоупінг. Не бійтесь з !important, якщо ви не любите налагоджувати в темряві.

Три корпоративні міні‑історії з шахт модалів

Інцидент: неправильне припущення щодо хешу

Команда випустила CSS‑ті :target модаль для «швидких деталей продукту» на сторінці магазину. На цьому шляху JS не дозволяли через ініціативу з продуктивності і беклог перевірки безпеки. Модаль виглядав добре й пройшов базове QA.

Через два тижні почали сипатися звернення в підтримку: користувачі потрапляли в дивні «порожні стани», і кнопка назад виглядала зламаною. На мобільних було ще гірше. Інцидент не був «сайт упав», але це призводило до відтоку конверсій, а в корпоративній термінології це тип відмови, яку помічають люди, що не знають, що таке CSS.

Неправильне припущення: вони вірили, що фрагменти хешу — це «локальний стан UI», що не зачіпає навігацію. Насправді кожне відкриття і закриття мутувало фрагмент URL, створюючи записи в історії. Користувачі, які відкривали деталі, закривали, а потім натискали Back, не поверталися на сторінку категорії. Вони повторно відкривали модаль. Натиснути Back ще раз — і іноді браузер прокручував до анкора близько до верху, де в DOM знаходився контейнер модалю. Тепер користувач загубився й роздратований.

Виправлення не було хитрим. Вони замінили :target на патерн з чекбоксом для цієї сторінки й прибрали посилання «#close» зовсім. Це усунуло шум в історії. Також вони скоротили дизайн модального вмісту, щоб зменшити бажання користувачів переходити туди‑сюди.

Висновок післяінциденту: якщо ваш стан UI змінює URL, це навігація. Розглядайте це як навігацію. Переглядайте так само ретельно.

Оптимізація, що відбілила телефони

Інша організація захотіла «преміум‑відчуття» з модалем із матовим склом. Дизайн приніс специфікацію з сильним ефектом розмиття бекдропа й плавною анімацією відкриття. На десктопі, який використовували для погоджень, це виглядало фантастично. У презентації — теж.

Команда реалізувала backdrop-filter: blur(14px) з напівпрозорою накладкою й переходом. На ранньому тестуванні все було добре. Потім це опинилося на реальній сторінці: багато зображень, липкий хедер і карусель. На середньому Android і старих iPhone відкривання модалю викликало джанк. Іноді сторінка зависала на мить. Іноді тапи не реєструвалися. Підтримка назвала це «періодичною не‑відповідальністю», що найнеприємніший клас багів, бо не відтворюється на вимогу.

«Оптимізація» полягала в перекладанні більшої роботи на GPU: додали трансформи для підвищення шару, примусили композитинг. Це створювало додаткові контексти стеку й іноді робило так, що модаль рендерився під липким хедером. Тепер кнопка закриття була частково закритою в деяких макетах. Чудово.

Вони відкотили розмиття й використали простий rgba‑оверлей. Залишили дуже помірне плавне зникання тільки для opacity. Продуктивність стабілізувалася. «Преміум‑відчуття» змінилося на «працює», а це — найпреміальніша можливість модального вікна.

Жарт №2: Кожного разу, коли ви додаєте фільтр blur до модалю, мобільний GPU подає офіційну скаргу.

Нудна, але правильна практика, яка врятувала день: один корінь для оверлеїв

Третя команда вела контент‑важкий сайт з десятками компонентів від різних команд. Їх уже неодноразово палили війни з z‑index і контекстами стеку, тому вони встановили нудне правило: будь‑який оверлей має монтуватися в єдиний верхній «overlay root», розташований в кінці <body>.

Це звучало бюрократично. Люди бурчали, бо це означало, що ви не можете просто помістити компонент модалю будь‑де й вважати справу зробленою. Але це принесло результат: передбачуваний стек, передбачуване позиціювання і менше «чому це під хедером» сюрпризів.

Коли їм знадобився CSS‑ті модал для мікросайту документації (JS не дозволяли через обмеження платформи), вони використали той самий overlay root. Це уникнуло трансформованих предків, бо overlay root був поза обгортками макета. Бекдроп завжди покривав вьюпорт. Z‑index був стабільний, бо визначений один раз.

Пізніше, коли вони додали липку промо‑панель з анімацією на основі трансформу (класичний генератор контексту стеку), це не зламало модаль. Overlay root все ще був над ним. Ніяких аварійних патчів, ніяких п’ятничних релізів, ніякого ганебного переливання звинувачень.

Практика не була хитрою. Вона була контрактом. Контракти запобігають інцидентам.

Чеклісти / покроковий план

Чекліст: вибір правильного CSS‑тіго патерну

  1. Якщо існує хеш‑роутинг (SPA, активне використання анкерів): надавайте перевагу checkbox (:checked).
  2. Якщо модаль має бути доступним по прямому посиланню й поведінка історії прийнятна: :target підходить.
  3. Якщо модаль містить форму або критичну дію користувача: не використовуйте лише CSS. Використовуйте JS або <dialog>.
  4. Якщо вам потрібне закриття по Escape: не використовуйте тільки CSS.

Чекліст: CSS‑ті модальне вікно, яке не зганьбить вас

  1. Корінь модалю використовує position: fixed; inset: 0;.
  2. Бекдроп — повноекранний елемент і перехоплює події вказівника.
  3. Діалог використовує max-width і max-height; вміст прокручується внутрішньо.
  4. Кнопка закриття має велику площу влучання й ARIA‑підпис.
  5. Закриття по бекдропу ввімкнене лише для не‑деструктивного контенту.
  6. Модаль змонтований в кінці body або поза трансформованими предками.
  7. Немає покладання на гонитву за z-index. Один контекст стеку, одне значення.
  8. Тестуйте при 200% зумі й збільшеному розмірі шрифтів.

Покроково: побудова :target модалю з розумною навігацією

  1. Створіть тригер‑лінк на #modal-id.
  2. Створіть контейнер модалю <div id="modal-id" class="modal"> ближче до кінця body.
  3. Додайте бекдроп‑лінк на нейтральний фрагмент (зазвичай #close) і лінк закриття всередині діалогу.
  4. Стилізуйте .modal як схований за замовчуванням; показуйте через .modal:target.
  5. Чітко вирішіть, чи прийнятна поведінка історії. Якщо ні — зупиніться й оберіть чекбокс або JS.

Покроково: побудова checkbox‑модалю, що переживе рефакторинг

  1. Розмістіть <input type="checkbox" class="modal-toggle"> безпосередньо перед модалем у DOM.
  2. Контроль відкриття — <label for="... ">; контроль закриття — інший label.
  3. Використовуйте .modal-toggle:checked ~ .modal для показу.
  4. Документуйте вимогу порядку DOM у README компоненту й коментарях коду.
  5. Додайте unit‑тест або статичну перевірку, що фейлить, якщо структура зміниться (так, навіть для CSS‑тих компонентів).

Запитання й відповіді

Чи може CSS‑ті модальне вікно бути повністю доступним?

Ні. Без JS ви не можете надійно замкнути фокус, відновити фокус або реалізувати закриття по Escape. Ви все ще можете зробити його менш шкідливим за допомогою правильних ролей, підписів і видимих контролів.

Що обрати: :target чи :checked?

Якщо ви хочете deep link і можете терпіти ефекти історії — :target. Якщо ви в додатку з роутером або дбаєте про поведінку кнопки «Назад» — :checked.

Чому мій модаль з’являється під липким хедером навіть з величезним z‑index?

Тому що z‑index не перетинає межі контекстів стеку. Батьківський transform або filter може ув’язнити ваш модаль у нижчому стекі.

Як запобігти прокручуванню фону без JS?

Іноді можна використати :has() для перемикання overflow: hidden на html. Без :has() найкращий варіант — зробити діалог прокручуваним всередині й зменшити ланцюження прокрутки.

Чи завжди добре закривати по кліку поза модалем?

Ні. Добре для інформаційних модалів і зображень. Ризиковано для форм чи підтверджень, бо випадковий клік може втратити роботу користувача.

Чому відкриття :target модалю іноді прокручує сторінку?

Навігація фрагментів може прокрутити до цілі. Фіксоване позиціонування часто маскує це, але розміщення в DOM і поведінка браузера все одно можуть спричиняти стрибки.

Чи можна насаджувати кілька CSS‑тих модалів?

Можна, але це крихке. :target таргетує лише один фрагмент одночасно. Checkbox‑модалі можна насладувати, але управління фокусом і z‑index швидко стає заплутаним.

Що з <dialog>?

Якщо JS дозволено хоч трохи, <dialog> — кращий примітив, ніж CSS‑хакі. Воно все одно потребує уважного UX‑планування, але ви починаєте ближче до правильної семантики.

Чи потрібні ARIA‑атрибути, якщо все «лише CSS»?

Якщо ви представляєте щось як діалог — так: role="dialog", aria-modal="true" і aria-labelledby — базова потреба. Вони не вирішують фокус, але зменшують плутанину.

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

  • Вирішіть, чи ви будуєте справжній модальний в додатку чи контент‑оверлей. Якщо це справжній UI додатку — зупиніться й заплануйте мінімальний JS.
  • Оберіть один CSS‑ті патерн і стандартизуйте його. Мішані патерни по сайту множать режими відмов.
  • Встановіть overlay root біля кінця body. Це нудно. Але це запобігає драмі з z‑index.
  • Запишіть обмеження. Якщо ви використовуєте патерн checkbox — документуйте вимогу порядку DOM як контракт.
  • Тестуйте як песиміст: 200% зум, великі шрифти, mobile Safari і сторінка з липкими хедерами та трансформами. Якщо пройде це — пройде більшість ваших користувачів.

Якщо ви запам’ятаєте одну річ: CSS‑ті модалі прийнятні, коли вони малі, передбачувані й чесні щодо своїх обмежень. Якщо вам потрібна коректність для клавіатури — візьміть правильний інструмент. Ваш канал інцидентів скаже вам дякую.

← Попередня
Debian 13: зміна SSH-порту — виправити порядок firewall + sshd без блокування доступу (випадок №27)
Наступна →
ZFS copies=2/3: Додаткова надмірність без нового VDEV — розумно чи марнотратно?

Залишити коментар