Сучасний CSS Holy Grail: шапка, підвал, бічна панель без хитрощів

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

Ви впізнаєте цей макет. Шапка вгорі, підвал унизу, бічна панель (або дві) і основний контент посередині,
який тягнеться на весь екран без дивних проміжків. Макет «Holy Grail». У 2025 році це має бути нудно.
Проте я досі бачу продакшн‑інтерфейси, склеєні негативними відступами, загадковими обгортками і підходом «на моєму лаптопі працює».

Ось сучасний, надійний спосіб це зробити: CSS Grid для каркасу сторінки, трохи Flexbox там, де він дійсно допомагає,
і кілька запобіжників, які зупиняють переповнення та баги зі скролом до того, як ваш on‑call отримає повідомлення від власного маркетингового сайту.

Що насправді означає «Holy Grail» (і чому він досі кусає)

Макет Holy Grail — це не просто «три колонки». Це відволікання. Справжня вимога — сторінка, яка
поводиться як оболонка додатку: шапка і підвал присутні, бічна навігація не ганяється по екрану,
а основна область росте, стискається і прокручується правильно на різних розмірах вікна і довжинах контенту.

Коли команди кажуть «ми реалізували Holy Grail», часто мають на увазі «ми змусили зʼявитися трьохколонний макет,
і тепер на iPhone з’являється горизонтальний скрол на 2px». Сучасна версія — це про правильність у стресі:
довгі підписи навігації, ненадійний контент, масштабований текст, крихітні екрани, гігантські екрани та вбудовані iframe.

Макет — це інженерія продакшну. Не тому, що це важко, а тому, що відмови тонкі, видимі користувачеві
і трапляються саме тоді, коли ваші керівники демонструють продукт на готельному Wi‑Fi з 125% масштабування браузера.

Коротка, конкретна історія: як ми сюди потрапили (факти, не ностальгія)

  • Факт 1: Оригінальний патерн «Holy Grail» набув популярності у середині 2000‑х, бо у CSS не було вбудованої двовимірної системи макетів; float‑и і clear‑fix працювали понаднормово.
  • Факт 2: Протягом років колонки рівної висоти були болючою проблемою; багато команд використовували faux‑колонки (фонові зображення), бо рушій макету не міг зробити це надійно.
  • Факт 3: Зростання адаптивного дизайну (початок 2010‑х) зробив фіксовані бічні панелі крихкими; макети треба було перевпорядковувати, а не просто зменшувати до поломки.
  • Факт 4: Flexbox (широко підтриманий у середині 2010‑х) вирішив одномірне вирівнювання, але «оболонка сторінки + рядки + колонки» є природно двовимірною; команди надмірно використовували Flexbox і натрапляли на пастки переповнення.
  • Факт 5: CSS Grid зʼявився у стабільних браузерах близько 2017 року і нарешті зробив макет Holy Grail першокласним: явні рядки й колонки, іменовані області та адекватне переставляння без зловживань DOM.
  • Факт 6: Патерни «прилиплий підвал» раніше базувалися на негативних відступах або гімнастиці з обгортками; Grid зробив це здебільшого однорядковим: grid-template-rows: auto 1fr auto;
  • Факт 7: min-width:auto і правила внутрішнього розміру здивували багатьох розробників; сучасний фікс (min-width:0 на дочірніх елементах grid/flex) став стандартною надійною практикою.
  • Факт 8: Контейнерні запити (широко впроваджені у 2020‑х) перенесли адаптивну логіку від «вгадування по viewport» до «реальності компонента», що важливо для бічних панелей у панелях та мікро‑фронтендів.

Незаперечні вимоги (те, що команди забувають)

Ви не можете вважати роботу виконаною, поки це не буде істинним. Роздрукуйте, якщо треба.

1) Сторінка повинна заповнювати вікно перегляду, навіть якщо контент короткий

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

2) Лише один контейнер прокрутки (якщо нема вагомої причини)

Більшість «оболонок додатків» повинні прокручувати сторінку, а не вкладений елемент. Вкладена прокрутка ламає функції браузера:
пошук на сторінці, навігація по анкерах, поведінку overscroll і інколи доступність. Якщо ви змушені мати вкладений скролер
(поширено в дашбордах), робіть це навмисно і тестуйте агресивно.

3) Бічні панелі не повинні викликати горизонтальний скрол

Довгі підписи, фрагменти коду і безперервні рядки — вороги. Надійна бічна панель обробляє переповнення обертанням рядків,
трикрапкою або контрольованою прокруткою. Крихка панель змушує всю сторінку ширшою за вікно, і ви отримуєте
проклятий тикет «чому з’явився горизонтальний скрол?».

4) Порядок у DOM повинен відповідати змісту

Grid дозволяє візуально переставляти блоки. Чудово. Але не використовуйте це, щоб приховати семантичний хаос.
Тримайте порядок DOM відповідним послідовності читання: header, nav, main, footer.
Ваші користувачі клавіатури і екранні рідери подякують, а макет буде простіше підтримувати.

Рішення рівня продакшну: CSS Grid як шасі сторінки

Ставтесь до сторінки як до інфраструктури. Потрібне шасі, що витримує форму і обмеження,
і компоненти всередині можуть вільно виконувати свою роботу. Це шасі — CSS Grid.

Найчистіший патерн: один grid для всієї сторінки з трьома рядками (шапка, тіло, підвал),
і всередині рядка body — другий grid для бічної панелі + основного контенту (і опційно aside).
Це відокремлює турботи: глобальний макет проти розміщення контенту.

Базовий HTML (семантичний, нудний, правильний)

cr0x@server:~$ cat index.html
<div class="app">
  <header class="header">
    <a class="skip-link" href="#main">Skip to content</a>
    <div class="brand">Acme Console</div>
    <nav class="topnav" aria-label="Top navigation">...</nav>
  </header>

  <div class="body">
    <nav class="sidebar" aria-label="Primary">...</nav>
    <main id="main" class="content">...</main>
    <aside class="aside" aria-label="Secondary">...</aside>
  </div>

  <footer class="footer">...</footer>
</div>

Обгортка .app — це оболонка. .body — місце для бічних панелей. Ця структура тримає
шапку і підвал стабільними і дає гнучкість всередині body.

Базовий CSS Grid (Holy Grail без косплею)

cr0x@server:~$ cat app.css
:root {
  --sidebar: 18rem;
  --aside: 16rem;
  --gap: 1rem;
  --border: 1px solid #e5e7eb;
}

* { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  font: 16px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}

.app {
  min-height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
}

.header {
  border-bottom: var(--border);
  padding: 0.75rem 1rem;
  background: white;
}

.body {
  display: grid;
  grid-template-columns: var(--sidebar) minmax(0, 1fr) var(--aside);
  gap: var(--gap);
  padding: 1rem;
}

.sidebar {
  border: var(--border);
  padding: 0.75rem;
  overflow: auto;
}

.content {
  min-width: 0;
  border: var(--border);
  padding: 1rem;
}

.aside {
  border: var(--border);
  padding: 0.75rem;
  overflow: auto;
}

.footer {
  border-top: var(--border);
  padding: 0.75rem 1rem;
  background: white;
}

Найважливіший рядок у всьому файлі — minmax(0, 1fr) для основної колонки, і резервний запобіжник
min-width: 0 на .content. Без цього довгий контент може змусити елемент grid розширитися,
і ви отримаєте горизонтальний скрол. Це один із тих випадків «виглядає як магія, насправді — поведінка специфікації».

Жарт 1: CSS‑макет — єдине місце, де min-width: 0 — акт оптимізму.

Адаптивне перепорядкування: ховати бічні панелі без дублювання DOM

На вузьких екранах зазвичай хочуть одну колонку, де бічна панель стає off‑canvas або переміщується вище контенту.
Ключ — змінювати grid‑шаблони і, при потребі, застосовувати клас‑перемикач. Не дублюйте розмітку навігації.

cr0x@server:~$ cat responsive.css
@media (max-width: 900px) {
  .body {
    grid-template-columns: 1fr;
  }
  .aside {
    display: none;
  }
}

@media (max-width: 700px) {
  .sidebar {
    display: none;
  }
  .app.has-drawer .sidebar {
    display: block;
    position: fixed;
    inset: 0 auto 0 0;
    width: min(85vw, var(--sidebar));
    background: white;
    z-index: 50;
    box-shadow: 0 10px 30px rgba(0,0,0,0.2);
  }
  .app.has-drawer .body {
    grid-template-columns: 1fr;
  }
}

Так, тут використовується position: fixed для drawer. Це нормально. Це не хак; це свідомий оверлей.
Хаком стає тоді, коли фіксований drawer випадково створює другу область прокрутки, ловить фокус
або ховає контент за шапкою. Розвʼязуйте це навмисно, а не молитвами.

Скрол і переповнення: справжнє джерело інцидентів макету

Поговоримо про те, що насправді ламається в продакшні: переповнення. Не теоретичне. Той випадок: «клієнт вставив довгий токен,
і тепер сторінка 4000px широка». Або «основний контент не прокручується, тільки бічна панель, і тач‑скрол працює неправильно».
Баги макету люблять крайні випадки, бо там ваші CSS‑припущення вмирають.

Золоте правило: вирішіть, хто прокручує

Варіант A: сторінка прокручується (за замовчуванням). Тримайте шапку статичною або sticky за потреби, але документальна прокрутка — основна.
Це гармонійно працює з поведінкою браузера, анкерами і доступністю.

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

Якщо має прокручуватись тільки основний контент — робіть це свідомо

cr0x@server:~$ cat nested-scroll.css
.app {
  height: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
}

.body {
  min-height: 0;
  display: grid;
  grid-template-columns: var(--sidebar) minmax(0, 1fr) var(--aside);
}

.content {
  min-height: 0;
  overflow: auto;
}

Зверніть увагу на min-height: 0 на .body і .content. Без цього елементи grid
можуть відмовитись стискатися, і переповнення вилізе на сторінку, створюючи заплутану подвійність прокрутки.

Безперервні рядки: ставтесь до них як до ненадійного вводу

Якщо ваш контент містить логи, ідентифікатори, base64‑блоки або трейс‑помилки, треба обробляти довгі безперервні рядки.
Надійний підхід:

cr0x@server:~$ cat overflow-strings.css
.content {
  overflow-wrap: anywhere;
  word-break: normal;
}

pre, code {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

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

Жарт 2: Один єдиний безперервний UUID у невдалому місці може навчити ваш макет більше скромності, ніж будь‑який дизайн‑ревʼю.

Доступність і стійкість: навігація, орієнтири та фокус

Grid дає силу макету. Використовуйте її, не роблячи інтерфейс ворожим для користувачів клавіатури та допоміжних технологій.
Макет Holy Grail майже підозріло узгоджується з семантичним HTML. Візьміть цю перемогу.

Орієнтири: header/nav/main/footer — не декорація

Використовуйте <header>, <nav>, <main>, <footer>.
Додавайте aria-label, коли є більше ніж одна навігація. Це не тільки доступність;
це полегшує відладку, бо DOM відображає намір.

Skip link: єдина функція, за яку всі вдячні, коли її немає

Skip link тривіальна і економить клавіатурним користувачам багато табів щоразу при завантаженні сторінки.
Робіть її видимою при фокусі.

cr0x@server:~$ cat skip-link.css
.skip-link {
  position: absolute;
  left: -999px;
  top: 0;
  padding: 0.5rem 0.75rem;
  background: #111827;
  color: white;
  border-radius: 0.25rem;
}

.skip-link:focus {
  left: 0.75rem;
  top: 0.75rem;
  z-index: 1000;
}

Цитата (парафразована), що стосується CSS теж

Парафразована ідея, приписувана John Gall: «Складна система, що працює, еволюціонувала з простішої системи, що працювала».
Побудуйте просту grid‑оболонку спочатку, а потім додавайте поведінку.

Практичні завдання: 12+ реальних перевірок з командами, виводами та рішеннями

Це ті перевірки, які я запускаю, коли макет «раптом» ламається. Команди локальні та CI‑дружні.
Сенс не в інструментах; сенс — дисципліна: виміряти, інтерпретувати, вирішити.

Завдання 1: Швидко перевірити семантику HTML

cr0x@server:~$ tidy -q -e index.html
line 14 column 5 - Warning: missing </nav> before </header>

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

Рішення: Виправте розмітку перед тим, як чіпати CSS. Баги макету через некоректний HTML — крадіжка часу.

Завдання 2: Перевірте випадкову вкладену прокрутку в обчисленому макеті (headless)

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.goto('http://localhost:8080');const r=await p.evaluate(()=>({body:document.body.scrollHeight,inner:window.innerHeight,contentScroll:document.querySelector('.content')?.scrollHeight}));console.log(r);await b.close();})();"
{ body: 2200, inner: 900, contentScroll: 900 }

Що це означає: Сторінка прокручується (body > inner). Область content у цьому прикладі не вища за вікно.

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

Завдання 3: Виявити горизонтальне переповнення на типових брейкпоінтах

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();for(const w of [375,768,1024]){await p.setViewport({width:w,height:800});await p.goto('http://localhost:8080');const o=await p.evaluate(()=>document.documentElement.scrollWidth-window.innerWidth);console.log(w,o);}await b.close();})();"
375 0
768 0
1024 0

Що це означає: Горизонтального переповнення на цих ширинах немає.

Рішення: Якщо будь‑яке значення позитивне — знайдіть елемент, що виходить за межі (див. Завдання 4) перед випуском.

Завдання 4: Визначити елемент, що викликає переповнення в DOM

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:375,height:800});await p.goto('http://localhost:8080');const offenders=await p.evaluate(()=>{const els=[...document.querySelectorAll('body *')];return els.map(e=>({tag:e.tagName,cls:e.className,w:e.getBoundingClientRect().width,sw:e.scrollWidth})).filter(x=>x.sw-x.w>2).slice(0,10);});console.log(offenders);await b.close();})();"
[ { tag: 'PRE', cls: '', w: 343, sw: 912 } ]

Що це означає: <pre> ширший за свого контейнера.

Рішення: Зробіть pre { overflow:auto; } або дозволяйте перенесення контенту; не давайте йому розширювати сторінку.

Завдання 5: Перевірте, чи Grid справді застосовано (немає регресій в CSS‑бандлі)

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.goto('http://localhost:8080');const d=await p.evaluate(()=>getComputedStyle(document.querySelector('.app')).display);console.log(d);await b.close();})();"
grid

Що це означає: Оболонка у режимі grid.

Рішення: Якщо бачите block, ваш CSS не завантажився або був перевизначений. Виправляйте пайплайн, а не макет.

Завдання 6: Підтвердити поведінку прилипаючого підвалу з малим контентом

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:1200,height:800});await p.goto('http://localhost:8080/?empty=1');const y=await p.evaluate(()=>{const f=document.querySelector('.footer');return Math.round(f.getBoundingClientRect().bottom);});console.log(y);await b.close();})();"
800

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

Рішення: Якщо значення менше за висоту вікна, ваш ланцюжок з min-height:100vh порвано.

Завдання 7: Виявити «min-width:auto» переповнення в основній колонці

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:1024,height:800});await p.goto('http://localhost:8080/?longtable=1');const x=await p.evaluate(()=>({contentMin:getComputedStyle(document.querySelector('.content')).minWidth,scroll:document.documentElement.scrollWidth-window.innerWidth}));console.log(x);await b.close();})();"
{ contentMin: '0px', scroll: 0 }

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

Рішення: Якщо contentMin дорівнює auto і ви бачите переповнення, встановіть min-width:0 на дочірньому елементі grid.

Завдання 8: Перевірте, що фокус не застрягає, коли бічна панель стає drawer

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:390,height:800});await p.goto('http://localhost:8080');await p.evaluate(()=>document.querySelector('.app').classList.add('has-drawer'));await p.keyboard.press('Tab');const a=await p.evaluate(()=>document.activeElement.className);console.log(a);await b.close();})();"
skip-link

Що це означає: Перший фокусований елемент — skip link (добре).

Рішення: Якщо фокус стрибає за оверлей, додайте управління фокусом і зробіть фон неактивним (inert) при відкритому drawer.

Завдання 9: Відловити зсуви макету (проксі CLS) шляхом зйомки позицій елементів до/після завантаження шрифтів

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.goto('http://localhost:8080');const before=await p.evaluate(()=>document.querySelector('.sidebar').getBoundingClientRect().width);await p.waitForTimeout(1500);const after=await p.evaluate(()=>document.querySelector('.sidebar').getBoundingClientRect().width);console.log({before,after});await b.close();})();"
{ before: 288, after: 288 }

Що це означає: Ширина sidebar стабільна; завантаження шрифтів не викликало перерахунку.

Рішення: Якщо ширини змінюються, розгляньте стратегію font‑display або уникайте макету, залежного від метрик тексту.

Завдання 10: Переконатися, що медіа‑запити спрацьовують як очікується

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:680,height:800});await p.goto('http://localhost:8080');const s=await p.evaluate(()=>getComputedStyle(document.querySelector('.sidebar')).display);console.log(s);await b.close();})();"
none

Що це означає: На 680px sidebar приховано (режим drawer).

Рішення: Якщо він все ще видимий, ваші breakpoint‑правила не завантажилися або були перекриті.

Завдання 11: Переконатися, що ви не відсилаєте ненавмисні CSS‑перекриття (перевірка бандлу)

cr0x@server:~$ rg -n "grid-template-columns:.*1fr" dist/assets/*.css | head
dist/assets/app.6f12.css:42:.body{display:grid;grid-template-columns:18rem minmax(0,1fr) 16rem;gap:1rem}

Що це означає: Побудований CSS містить ваше призначене правило grid.

Рішення: Якщо бачите кілька конфліктних визначень пізніше у файлі, виправте порядок або специфічність; не виводьтеся з !important.

Завдання 12: Виявити несподівані проблеми зі стекінгом (drawer за шапкою)

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.setViewport({width:390,height:800});await p.goto('http://localhost:8080');await p.evaluate(()=>document.querySelector('.app').classList.add('has-drawer'));const z=await p.evaluate(()=>({sidebar:getComputedStyle(document.querySelector('.sidebar')).zIndex,header:getComputedStyle(document.querySelector('.header')).zIndex}));console.log(z);await b.close();})();"
{ sidebar: '50', header: 'auto' }

Що це означає: Оверлей‑sidebar має явний z‑index; header — ні. Швидше за все drawer буде вище за нього.

Рішення: Якщо header має вищий стекінг через інший контекст, встановіть sidebar z‑index відповідно або приберіть випадкові трансформації, що створюють нові стекінг‑контексти.

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

cr0x@server:~$ node -e "const puppeteer=require('puppeteer');(async()=>{const b=await puppeteer.launch({headless:'new'});const p=await b.newPage();await p.goto('http://localhost:8080');const scrollers=await p.evaluate(()=>{const els=[document.documentElement,document.body,...document.querySelectorAll('*')];return els.map(e=>{const cs=getComputedStyle(e);return {tag:e.tagName||'HTML',cls:e.className||'',ov:cs.overflowY,sh:e.scrollHeight,ch:e.clientHeight};}).filter(x=>['auto','scroll'].includes(x.ov) && x.sh>x.ch+5).slice(0,10);});console.log(scrollers);await b.close();})();"
[ { tag: 'HTML', cls: '', ov: 'auto', sh: 2200, ch: 800 } ]

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

Рішення: Якщо ви бачите .body або .content тут несподівано, ви створили вкладену прокрутку; вирішіть, чи це заплановано, і стандартизуйте це.

Швидкий план діагностики (знайти вузьке місце за кілька хвилин)

Коли макет Holy Grail «ламається», зазвичай винен один з чотирьох факторів: відсутні обмеження висоти,
внутрішнє переповнення через інфраструктурні розміри, вкладені контейнери прокрутки або перевизначення/регресія CSS.
Цей план — найкоротший шлях до правди.

Перше: ідентифікуйте категорію симптомів

  • Підвал не внизу при короткому контенті: проблема з ланцюжком height/min-height.
  • Зʼявився горизонтальний скрол: переповнення/інтринсичні розміри (часто min-width:auto або безперервні рядки).
  • Два скроли / «основний не прокручується»: вкладена прокрутка і за замовчуванням min-height.
  • Макет неправильний тільки в проді: порядок CSS‑бандлу, відсутній файл або застарілий кешований CSS.

Друге: перевірте обмеження (перевірка «чи ми взагалі можемо стиснути?»)

  • Чи оболонка має min-height: 100vh (прокрутка сторінки) або height: 100vh (вкладена прокрутка)?
  • Чи середній рядок використовує 1fr?
  • Чи дочірні елементи grid, що мають стискатися, мають min-width: 0 і/або min-height: 0?

Третє: точно локалізуйте переповнення

  • Перевірте documentElement.scrollWidth - innerWidth, щоб довести наявність переповнення.
  • Знайдіть елементи‑порушники, де scrollWidth > clientWidth.
  • Виправте порушника: обгорніть текст, обмежте pre, правильно встановіть мін‑розміри або дозвольте стиск.

Четверте: переконайтесь, що нема перевизначень

  • Перевірте обчислені стилі в DevTools для display: grid і очікуваних значень grid-template-*.
  • Шукайте в зібраному CSS кілька визначень одних і тих же селекторів.
  • Видаліть !important «пластирі» і виправте специфічність/порядок.

Поширені помилки: симптом → коренева причина → виправлення

1) Симптом: на десктопі зʼявився горизонтальний скрол

Коренева причина: Основна колонка — 1fr, але елемент grid має інтринсичну мін‑ширину і відмовляється стискатися (класична проблема min-width:auto), або дочірній елемент, як pre, переповнює.

Виправлення: Використовуйте minmax(0, 1fr) для основної колонки і встановіть min-width: 0 на дочірньому елементі grid. Обмежте блоки коду стилем pre { overflow:auto; }.

2) Симптом: підвал висить над низом при короткому контенті

Коренева причина: Оболонка не заповнює вікно (відсутнє min-height:100vh), або ви використали відсоткові висоти без встановлення ланцюжка html, body height.

Виправлення: Віддайте перевагу .app { min-height:100vh; grid-template-rows:auto 1fr auto; }. Якщо використовуєте height:100%, встановіть html, body { height:100%; } і розумійте компроміси.

3) Симптом: основний контент не прокручується, але бічна панель прокручується

Коренева причина: Ви випадково встановили overflow:auto на sidebar, але не на main, або створили вкладену прокрутку з фіксованою висотою і забули min-height:0 на елементах grid.

Виправлення: Вирішіть, хто має прокручуватись. Якщо main має прокручуватись, встановіть .content { overflow:auto; min-height:0; } і переконайтесь, що предки дозволяють це (.body { min-height:0; }).

4) Симптом: drawer‑панель зʼявляється, але контент під нею все ще клікабельний

Коренева причина: Оверлей не має backdrop і ви не відключили pointer events на фоні при відкритті.

Виправлення: Додайте backdrop‑елемент і встановіть inert на основну оболонку при відкритому drawer (з fallback), або керуйте pointer events явно.

5) Симптом: макет в деві нормальний, а в проді зламався

Коренева причина: Порядок CSS‑бандлу, tree‑shaking видалив «незадіяні» селектори або кешований CSS служить старий файл з новою HTML‑структурою.

Виправлення: Зробіть збірки детермінованими, додайте cache‑busting і простий smoke‑тест, що перевіряє обчислений display та переповнення на ключових брейкпоінтах.

6) Симптом: колонка контенту стискається до нечитаємої ширини при двох бічних панелях

Коренева причина: Обидві бічні панелі мають фіксовані ширини і немає політики, щоб прибрати одну, тому основна колонка страждає.

Виправлення: Додайте правила на брейкпоінтах: ховайте вторинний aside під порогом ширини, або дозвольте йому стискатися до 0 з minmax(0, var(--aside)) і display:none на менших екранах.

7) Симптом: «прилиплий» header перекриває контент при збільшенні масштабу

Коренева причина: Ви використали position: sticky і не зарезервували простір або не врахували зміну висоти шапки при зумі/великих шрифтах.

Виправлення: Уникайте жорстко закодованих відступів; дозвольте макету текти. Якщо потрібен sticky header, тримайте його в потоці документа і додавайте верхній padding контенту лише коли це необхідно.

Три корпоративні міні‑історії з окопів макетів

Міні‑історія 1: Інцидент через хибне припущення

SaaS‑консоль адміністрування впровадила «новий досвід навігації». Запит на зміну був невинний:
«Не переносити елементи навігації, виглядає чистіше». Хтось додав white-space: nowrap до посилань навігації.
На їхній машині все виглядало чудово.

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

Підтримка підняла це як «зламана data‑grid». Інженери спочатку ганяли компонент таблиці.
Це не була таблиця. Сторінка була ширшою за екран. Таблиця була лише жертвою.

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

Виправлення не полягало в «дозволити перенос скрізь». Виправлення — у політиці: підписи sidebar можуть переноситись до двох рядків,
потім …; ширина sidebar обмежена; основна колонка використовує minmax(0, 1fr) і дочірній елемент має min-width:0.
Вони також додали автоматичний тест переповнення на брейкпоінтах. Нудно. Ефективно.

Міні‑історія 2: Оптимізація, що обернулась проти команди

Інша команда хотіла швидших переходів між сторінками. Вони помітили перерахунок макету під час переключення sidebar
і вирішили «оптимізувати», перетворивши всю оболонку на один flex‑контейнер і анімувати ширини.
Flexbox скрізь. Одна модель, що панує над усім.

Це покращило один бенчмарк: на висококласному MacBook переключення sidebar стало плавнішим. Але на середніх Windows‑машинах
додаток почав втрачати кадри під час скролу. Причина не в самій sidebar; проблема — сукупний ефект частих інвалідтацій макету у великій flex‑ієрархії.
Кожна невелика зміна контенту змушувала браузер робити багато обчислень.

Гірше, вони ввели баг вкладеної прокрутки. Щоб тримати підвал видимим, вони закріпили висоти і додали
overflow:auto до середнього контейнера. Тепер було дві області прокрутки: сторінка і контент.
Коліщатко миші поводилося непослідовно. Тачпад відчувався «залипаючим». Page Down іноді нічого не робив.

Повернення назад не через те, що Flexbox поганий. А тому, що вони намагалися використовувати його як двовимірну систему.
Flexbox відмінний для рядків. Оболонки сторінки — для grid.

Вони відкотили зміни до grid‑оболонки з внутрішнім body‑grid і залишили Flexbox у компонентах (панелі інструментів, меню, заголовки карт).
Toggle‑и sidebar стали простим класом, що змінює колонки grid або трансформує overlay drawer.
Продуктивність відновилася, а поведінка прокрутки стала передбачуваною.

Міні‑історія 3: Нудна, але правильна практика, що врятувала вихідні

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

Однієї пʼятниці благовидний рефактор CSS пройшов. Хтось видалив min-width: 0 з основної області контенту,
бо «це нічого не робить». У локальному тестуванні нічого помітного не зламалося, бо їхній набір даних не містив довгих рядків.

CI спіймав це одразу. Тест переповнення на 1024px впав, вказавши на блок коду в основному контенті, що розширив grid‑елемент.
Інженер відновив min-width:0, додав коментар, чому це необхідно, і продовжив роботу.
Жодного інциденту. Жодного вікенду.

Практика була болісно непомітна: кілька цілеспрямованих, детермінованих перевірок.
Але, як і більшість роботи з надійності, цінність у відсутності інциденту.

Контрольні списки / покроковий план

Чекліст A: Впровадити правильний макет Holy Grail за один спринт

  1. Визначте політику прокрутки: сторінка прокручується або контент прокручується. Запишіть. Застосуйте в CSS.
  2. Побудуйте семантичну структуру: header, nav, main, footer. Додайте skip link. Уникайте перестановок DOM.
  3. Реалізуйте grid‑оболонку: grid-template-rows: auto 1fr auto і min-height: 100vh.
  4. Реалізуйте body‑grid: sidebar + minmax(0,1fr) main + опційний aside.
  5. Додайте запобіжники стискання: min-width:0 на main, min-height:0 при вкладеній прокрутці.
  6. Обробіть довгі рядки: вирішіть перенос або обмеження; не дозволяйте їм розширювати сторінку.
  7. Адаптивна політика: вирішіть, коли aside зникає; як sidebar стає drawer.
  8. Клавіатура і фокус: skip link працює, drawer не ловить і не пропускає фокус.
  9. Додайте інваріанти тестів: перевірки переповнення на 375/768/1024; перевірка прилипаючого підвалу; перевірка display grid.
  10. Програйте дивний контент: довгі підписи навігації, гігантські таблиці, великі шрифти, 200% zoom.

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

  1. Не припускайте viewport: використовуйте контейнерні запити там, де можливо, а не тільки media queries по viewport.
  2. Уникайте конфліктів глобальних скидів: тримайте CSS оболонки локалізованим і передбачуваним.
  3. Установлюйте containment свідомо: не додавайте випадково contain: layout або overflow:hidden для «оптимізації». Спочатку вимірюйте.
  4. Експонуйте токени макету: використовуйте CSS‑змінні для ширини sidebar, відступів і меж, щоб хости могли налаштовувати без форку коду.

Чекліст C: Гарантії надійності, що окупаються

  1. Автоматизуйте виявлення переповнення: вимірюйте scrollWidth - innerWidth на брейкпоінтах у CI.
  2. Тестуйте порожній і екстремальний контент: порожній main, гігантський main, довгі безперервні рядки, довгі локалізовані підписи.
  3. Забороніть загадкові обгортки: кожна обгортка має виправдання (обмеження висоти, grid‑контейнер або a11y‑причина).
  4. Коментуйте дивні рядки: min-width:0 і min-height:0 заслуговують коментаря, бо хтось їх «почистить».

Питання та відповіді

1) Чи використовувати CSS Grid чи Flexbox для макета Holy Grail?

Grid для шасі сторінки. Flexbox — всередині компонентів. Якщо ви спробуєте змусити Flexbox робити двовимірний макет,
рано чи пізно винайдете Grid погано і будете дебажити о 2 ранку.

2) Чому потрібен minmax(0, 1fr)? Хіба 1fr не достатньо?

1fr бере участь у внутрішньому розрахунку розмірів. Елемент grid може відмовитись стискатися, бо його дефолтна мін‑ширина
базується на контенті. minmax(0, 1fr) явно дозволяє треку стискатися нижче «переважної» ширини контенту.
Це різниця між «макет адаптується» і «макет переповнюється».

3) Чому min-width: 0 фіксує переповнення у дочірніх елементів grid і flex?

Бо дефолтна мін‑ширина часто auto, що може вирішуватися як інтринсичний мінімум.
Встановлення min-width: 0 каже рушію макету «дозволь цьому елементу стискатися», тож довгий контент
не змушує контейнер розширюватися.

4) Чи можна тримати шапку sticky, поки сторінка прокручується?

Так: .header { position: sticky; top: 0; }. Але тестуйте при зумі і великих шрифтах.
Sticky‑шапки можуть накривати контент, якщо ви також використовуєте відступи або якщо висота шапки динамічно змінюється.

5) Чи погано, коли основний контент прокручується всередині фіксованої оболонки?

Не обовʼязково. Це поширено в дашбордах. Ризик — вкладена прокрутка: зламані анкери, заплутана поведінка прокрутки
і непослідовна робота коліщатка/тачпада. Якщо обираєте вкладену прокрутку, примусьте min-height:0 і тестуйте на різних пристроях.

6) Який найчистіший спосіб зробити off‑canvas бічну панель?

Використовуйте ту ж розмітку sidebar і переключайте клас, який перетворює її на фіксований оверлей (або на drawer на основі transform),
додавайте backdrop і керуйте фокусом. Уникайте дублювання навігації в двох місцях; саме так зʼявляється дрейф і баги.

7) Як поводитись з двома бічними панелями, щоб не розчавити основний контент?

Встановіть політику брейкпоінтів: вторинний aside зникає першим. Використовуйте minmax(), щоб дозволити трекам стискатися,
і уникайте фіксованих ширин, що залишають основну область без простору.

8) Чи важливі контейнерні запити для цього макету?

Вони важливі, коли оболонка вбудована або коли sidebar живе всередині змінного за розміром панеля.
Запити по viewport припускають, що ваш компонент володіє екраном. У корпоративних додатках часто це не так.

9) Чому мій підвал зникає, коли я ставлю height: 100%?

Тому що height: 100% залежить від явного визначення висот предків. Якщо цей ланцюжок порваний,
ви отримуєте непередбачувані результати. Використовуйте min-height: 100vh для оболонки, якщо немає конкретної причини інакше.

10) Який найбезпечніший дефолт для блоків коду в основній області?

Робіть pre горизонтально прокручуваним і тримайте його в межах контейнера. Це рятує сторінку від розширення.
Якщо треба переносити, використовуйте white-space: pre-wrap і overflow-wrap: anywhere, але це впливає на читабельність.

Наступні кроки, які можна відправити цього тижня

Якщо ваш макет і досі покладається на float, clearfix‑хитрощі або негативні відступи, ви не «класичні».
Ви підписалися на дивні баги. Перенесіть оболонку на Grid. Тримайте це просто: три рядки, далі body‑grid.
Додайте два запобіжники, які більшість команд пропускає: minmax(0, 1fr) і min-width: 0.

Потім зробіть роботу з надійності, яка здається зайвою, поки не врятує вас: автоматизуйте перевірки переповнення на кількох брейкпоінтах,
тестуйте з абсурдним контентом і коментуйте неочевидні рядки, щоб вони пережили наступний рефактор.
Зробіть макет Holy Grail нудним. Оце справжня перемога.

← Попередня
BlackBerry і довге прощання: як клавіатури програли сенсорним екранам
Наступна →
ZFS zpool iostat -v: як знайти диск, що псує затримку

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