Frontend-разработка Вёрстка: flexbox, grid, позиционирование, адаптивность и контейнерные запросы
0%

Вёрстка: flexbox, grid, позиционирование, адаптивность и контейнерные запросы

Вёрстка: flexbox, grid, позиционирование, адаптивность и контейнерные запросы

Вёрстка долго кажется набором заклинаний: «поставь display: flex, потом align-items: center, а если поехало — добавь min-width: 0». Это ощущение возникает у всех, кто учит свойства, но не учит алгоритм. На самом деле CSS-раскладка — переговоры о размерах между родителем и потомком, а «магические» свойства лишь настраивают правила этих переговоров. Когда модель переговоров в голове есть, поведение перестаёт быть случайным: вы не подбираете значения, а предсказываете их.

Предполагается, что вы прошли CSS с нуля — каскад, специфичность, кастомные свойства, единицы — и представляете, откуда берётся дерево, которое мы раскладываем (Как работает браузер).

1. Два вопроса, из которых состоит любая раскладка

Когда браузер считает layout, для каждого бокса решаются ровно два вопроса: сколько места даёт родитель (available space — зависит от алгоритма форматирования родителя: поток, flex, grid, таблица) и сколько места хочет содержимое (intrinsic size). У второго вопроса три канонических ответа, и они доступны прямо в CSS:

.box { inline-size: min-content; }  /* самый узкий размер без переполнения ≈ ширина самого длинного слова */
.box { inline-size: max-content; }  /* всё в одну строку, переносов нет вообще */
.box { inline-size: fit-content; }  /* min(max-content, доступное место), но не уже min-content */

Практически каждое ключевое слово раскладки — функция от этой тройки. width: auto у блочного элемента значит «займи всё доступное место», у inline-block то же auto значит fit-content; flex-basis: auto без заданной ширины означает max-content; трек грида 1fr раскрывается в minmax(auto, 1fr), где auto — это min-content. Не понимаете, почему элемент такой ширины — спросите: какое из трёх значений применилось и кто его выбрал. Ответ обычно находится за пятнадцать секунд. Отсюда же вечное «почему кнопка растянулась»: у неё display: block из вашего же сброса, а auto в блочном потоке — это вся ширина родителя. Второе правило гигиены: пишите логические свойстваinline-size/block-size вместо width/height, margin-inline, padding-block, inset-inline-start. Они сами переворачиваются в RTL-локалях и вертикальных режимах письма, и интернационализация перестаёт требовать отдельной таблицы стилей (Ahmad Shadeed, «Digging Into CSS Logical Properties»). Дальше используются именно они.

2. Карта механизмов

Историческая справка в одну строку: float для макета, clearfix, сетки на процентах и отрицательные margin — археология. Flexbox (2012) и grid (2017) не конкуренты, а инструменты для разных задач; с 2023 к ним добавились контейнерные запросы и subgrid во всех движках, с 2024–2025 — anchor positioning.

3. Поток, box model и контексты форматирования

Прежде чем включать flex, полезно понимать, что вы выключаете. Нормальный поток кладёт блочные боксы друг под другом, инлайновые собирает в строки. Это единственный алгоритм, который сам умеет переносить текст, и самый дешёвый по стоимости пересчёта.

/* Сброс, который ставится один раз на проект */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { scrollbar-gutter: stable; }              /* страница не дёргается при появлении скроллбара */
body { min-block-size: 100dvh; line-height: 1.5; -webkit-text-size-adjust: none; }
img, picture, svg, video { display: block; max-inline-size: 100%; block-size: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
p { overflow-wrap: break-word; text-wrap: pretty; }
h1, h2, h3 { text-wrap: balance; }              /* заголовок не оставляет одно слово на строке */

box-sizing: border-box включает padding и border в заданный размер: без него inline-size: 100% вместе с padding: 1rem даёт переполнение — самая частая боль новичка. Схлопывание margin. В блочном потоке вертикальные margin соседей не складываются, а схлопываются в максимум из двух; более того, margin первого потомка «протекает» наружу через родителя, у которого нет padding, border или собственного контекста форматирования. Лечится созданием контекста блочного форматирования (BFC): .card { display: flow-root; }. BFC — «остров»: не схлопывает margin с внешним миром и не заезжает под float. Создают его display: flow-root, любое overflow кроме visible, flex- и grid-контейнеры, position: absolute, contain: layout, таблицы. Исторически писали overflow: hidden — теперь есть честный flow-root, который не режет тени, не создаёт скролл-контейнер и не ломает position: sticky внутри. Внутри flex- и grid-контейнеров схлопывания margin нет вообще: одна из причин, почему на них верстать предсказуемее.

4. Flexbox: одна ось и торг за свободное место

Flexbox одномерен: раскладывает элементы вдоль главной оси (flex-direction) и выравнивает поперёк — по поперечной. Инициатива у контента: размеры рождаются из содержимого, а контейнер распределяет излишек или дефицит.

Как flexbox распределяет свободное место между элементами

Алгоритм в четыре шага — выучите его, и flexbox перестанет удивлять:

  1. Каждому элементу считается базисflex-basis. При auto берётся inline-size, а если и он auto — размер по контенту (max-content, ограниченный доступным местом).
  2. Базисы складываются и сравниваются с внутренним размером контейнера. Сумма меньше — есть свободное место, работает flex-grow; больше — дефицит, работает flex-shrink.
  3. Свободное место делится пропорционально коэффициентам flex-grow. Дефицит — пропорционально произведению «flex-shrink × базис», поэтому при нехватке места крупный элемент ужимается сильнее мелкого. Асимметрия неочевидна, но логична: сжать на 50% элемент в 40px и элемент в 400px — разный ущерб.
  4. Если место осталось (никто не растёт), его распределяет justify-content.
.item { flex: 1; }        /* = 1 1 0%    — базис 0, элементы станут ОДИНАКОВЫМИ по размеру */
.item { flex: auto; }     /* = 1 1 auto  — базис по контенту, размеры ПРОПОРЦИОНАЛЬНЫ содержимому */
.item { flex: none; }     /* = 0 0 auto  — не растёт и не сжимается: иконки, аватары */
.item { flex: 0 1 auto; } /* значение по умолчанию: не растёт, но сжимается */
.item { flex: 2 1 12rem; }/* растёт вдвое активнее соседей, стартуя с 12rem */

Разница между flex: 1 и flex: auto — половина багов в «равномерных» сетках: с flex: auto колонка с длинным заголовком оказывается шире соседей, и это выглядит как случайность. Нужны одинаковые колонки — только flex: 1.

Ловушка автоматического минимума

По спецификации автоматический минимальный размер flex-элемента по главной оси не даёт ему стать уже своего min-content. Поэтому длинный URL, <pre> с кодом или широкая таблица раздувают контейнер и мешают text-overflow: ellipsis сработать:

.row { display: flex; gap: 1rem; }
.row > .truncated {
  min-inline-size: 0;   /* снимаем автоматический минимум, иначе многоточие не появится */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

При flex-direction: column то же происходит с min-block-size: auto — типичный симптом «внутренний скролл не скроллится, страница растянулась». В гриде болезнь та же, лекарство другое: minmax(0, 1fr) вместо 1fr. Эти две строки стоит помнить наизусть.

Свойство Ось Где ставится Что делает
justify-content главная контейнер распределяет свободное место между элементами
align-items поперечная контейнер выравнивает элементы внутри строки
align-self поперечная элемент переопределяет align-items для одного элемента
align-content поперечная контейнер распределяет строки; только при flex-wrap: wrap
gap обе контейнер жёлоб между элементами; по краям не добавляется
margin: auto любая элемент съедает всё свободное место с этой стороны

Последняя строка — самый недооценённый приём: .logo { margin-inline-end: auto; } в flex-шапке прижимает остальное вправо и, в отличие от justify-content: space-between, не ломается, когда в шапку добавят третий и четвёртый блок.

Два рецепта из Every Layout закрывают большинство одномерных задач и адаптируются без медиазапросов, реагируя на свою ширину, а не на ширину окна:

/* Sidebar: колонка фиксированной ширины + резиновый контент.
   Когда контенту тесно (меньше 50% ширины), колонки сами встают друг под друга. */
.with-sidebar { display: flex; flex-wrap: wrap; gap: 1rem; }
.with-sidebar > .sidebar { flex-basis: 15rem; flex-grow: 1; }
.with-sidebar > .content { flex-basis: 0; flex-grow: 999; min-inline-size: 50%; }

/* Switcher: N колонок в ряд, ниже порога 40rem — одна колонка. При узком контейнере
   calc уходит в минус, умножение на 999 делает базис огромным -> происходит перенос. */
.switcher { display: flex; flex-wrap: wrap; gap: 1rem; }
.switcher > * { flex-grow: 1; flex-basis: calc((40rem - 100%) * 999); }

order и доступность. order, row-reverse и column-reverse меняют визуальный порядок, но не DOM-порядок: таб и скринридер идут по разметке. Правило: визуальный порядок совпадает с DOM-порядком, нужен другой — меняйте разметку. Подробности — в руководстве по доступности.

5. Grid: две оси, линии и области

Grid двумерен: вы объявляете сетку, а элементы занимают в ней области. Принципиальное отличие от flexbox — раскладку диктует контейнер, а не сумма желаний детей.

CSS Grid: нумерация линий, треки, gap, auto-fill и auto-fit

.grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: minmax(3rem, auto);  /* неявные строки — для того, что не влезло в явную сетку */
  gap: 12px;
}
.hero { grid-column: 2 / 4; grid-row: 1 / 3; }  /* по номерам ЛИНИЙ, а не ячеек */
.wide { grid-column: span 2; }                  /* «займи два трека там, где встанешь» */
.last { grid-column: 2 / -1; }                  /* -1 — последняя линия; надёжнее числа */

Три вещи про треки. Первое: fr — доля свободного места, а не доля общей ширины; если рядом трек 200px и gap, 1fr делит только остаток. Второе: минимум трека 1fr равен auto, то есть min-content, поэтому длинное слово или широкая таблица раздувают колонку и «идеально равные» колонки перестают быть равными — отсюда minmax(0, 1fr). Третье: fit-content(20rem) — трек по контенту, но не шире 20rem; идеален для колонки подписей в форме.

auto-fit против auto-fill — самая полезная строчка в CSS

/* «RAM»: Repeat, Auto, Minmax — адаптивная сетка карточек без единого медиазапроса */
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr)); }

auto-fill создаёт максимум треков, которые влезают, включая пустые. auto-fit создаёт столько же, но схлопывает пустые в ноль, и оставшиеся 1fr разбирают освободившуюся ширину. Значит: auto-fit — когда две последние карточки должны растянуться на всю строку; auto-fill — когда карточки обязаны сохранять размер, а сетка — регулярность (ряд аватаров). Обёртка min(16rem, 100%) спасает от горизонтального скролла на узких экранах: без неё минимум трека жёстко равен 16rem.

Именованные области и full-bleed

.page {
  display: grid; gap: 1rem; min-block-size: 100dvh;
  grid-template:
    "header header" auto
    "nav    main"   1fr
    "footer footer" auto / 16rem minmax(0, 1fr);
}
.page > header { grid-area: header; }  .page > nav    { grid-area: nav; }
.page > footer { grid-area: footer; }  .page > main   { grid-area: main; }

@media (width < 48rem) {          /* синтаксис диапазонов читается лучше max-width */
  .page { grid-template: "header" auto "main" 1fr "nav" auto "footer" auto / minmax(0, 1fr); }
}

Весь макет виден в одном объявлении, а перестройка под мобильный — одна строка вместо десяти правил grid-column. Именованные линии дают ещё приём — full-bleed: grid-template-columns: [full-start] minmax(1rem, 1fr) [content-start] min(65ch, 100% - 2rem) [content-end] minmax(1rem, 1fr) [full-end], после чего текст ставится в grid-column: content, а иллюстрации — в grid-column: full и выходят на всю ширину без отрицательных margin (пара линий *-start/*-end автоматически создаёт неявную область с этим именем).

Авторазмещение, dense и subgrid

Элементы без явных координат расставляет алгоритм авторазмещения: курсор идёт по строкам (или по колонкам при grid-auto-flow: column) и кладёт элемент в первое подходящее место, не возвращаясь назад — поэтому после широкого элемента остаются дырки. grid-auto-flow: dense разрешает возврат и заполняет их: красиво для галереи, но, как и order, рассинхронизирует визуальный и табовый порядок.

Subgrid решает задачу, которую до 2023 года закрывали только скриптом: выровнять внутренности независимых карточек. Заголовки разной высоты — и кнопки «съезжают», потому что внутренние гриды ничего не знают друг о друге. Subgrid отдаёт карточке строки родителя:

.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1rem; }
.card {
  grid-row: span 3;             /* карточка занимает три строки внешней сетки */
  display: grid;
  grid-template-rows: subgrid;  /* title / text / actions ложатся ровно на эти строки */
  gap: 0.5rem;                  /* дочерний gap можно переопределить, иначе наследуется */
}

Теперь заголовки, тексты и кнопки всех карточек ряда выровнены по общим линиям — при любом контенте и без JS. Про masonry (кладку): спецификация несколько лет обсуждалась в двух вариантах (grid-template-rows: masonry и более общий item-flow), реализации живут за флагами — в проде используйте колонки грида, columns или JS.

Единая модель выравнивания

justify-* и align-* — не «свойства флексбокса», а отдельный модуль CSS Box Alignment, общий для нескольких раскладок. Мнемоника: justify — вдоль инлайн-оси (во flexbox — вдоль главной), align — вдоль блочной (поперечной).

/* Центрирование по обеим осям — три способа, все валидны */
.center-flex { display: flex; place-content: center; }           /* place-content = align + justify */
.center-grid { display: grid; place-items: center; }
.center-flow { align-content: center; min-block-size: 100dvh; }  /* блочный поток, без flex и grid */

justify-items/justify-self работают только в гриде (во flexbox их роль играет margin: auto), а align-content в блочном потоке — новая возможность свежих браузеров: центрировать больше не значит «включить flex». Отдельно стоит align-items: baseline: когда рядом подпись 12px и цифра 32px, center даёт оптически кривой результат, а baseline — типографски правильный.

6. Flexbox или grid: как выбирать

Честно, без фанатизма:

  • Flexbox выигрывает, когда число и размеры элементов заранее неизвестны: тулбары, списки тегов, кнопки в футере карточки, «прижать одно вправо». Разметка дешевле, добавление детей ничего не ломает, перенос строк бесплатный.
  • Grid выигрывает, когда раскладка — контракт страницы: макет приложения, сетка карточек, форма в две колонки, выравнивание между независимыми компонентами (subgrid), наложение элементов в одной ячейке (grid-area: 1 / 1 всем детям — честная замена position: absolute для подписи поверх картинки). Их сочетают: grid для страницы и секций, flex внутри компонентов; спор «что лучше» бессмыслен так же, как спор «массив или словарь».

7. Позиционирование и контексты наложения

.rel   { position: relative; inset-block-start: 4px; } /* смещение от своего места, место в потоке сохраняется */
.abs   { position: absolute; inset: 0; }               /* вне потока, координаты от containing block */
.fixed { position: fixed; inset-block-end: 1rem; }     /* от вьюпорта... почти всегда */
.stick { position: sticky; inset-block-start: 0; }     /* поток + прилипание при скролле */

Containing block — главный источник сюрпризов. Для absolute это ближайший предок с position не static. Для fixed это вьюпорт, но: если у любого предка есть transform, filter, perspective, backdrop-filter, contain: paint/layout, container-type не normal или will-change на любом из этих свойств — он сам становится containing block. Классический баг продакшена: полноэкранная модалка сломалась после того, как кто-то добавил предку transform: translateZ(0) «ради ускорения анимации».

position: sticky требует трёх условий сразу: задан хотя бы один порог (inset-block-start и т. п.), есть скроллящийся предок, и ни у одного промежуточного предка нет overflow: hidden/auto/scroll. Прилипание живёт только в пределах родителя: уехал родитель — уехал элемент. Нужно обрезать содержимое, но сохранить sticky — используйте overflow: clip: он обрезает, но не создаёт скролл-контейнер.

/* background обязателен, иначе строки просвечивают; z-index — иначе тело таблицы нарисуется поверх */
thead th { position: sticky; inset-block-start: 0; background: Canvas; z-index: 1; }
:target  { scroll-margin-block-start: 5rem; }  /* якорь не уезжает под липкую шапку */

z-index: 999999 не помогает — и это не баг: z-index сравнивается только внутри одного контекста наложения. Новый контекст создают position + z-index не auto, opacity < 1, transform, filter, mix-blend-mode, isolation: isolate, contain: paint, will-change на этих свойствах и элементы верхнего слоя.

Практика, закрывающая войны z-index навсегда: (1) шкала слоёв токенами --z-sticky: 100; --z-dropdown: 200; --z-modal: 1000 — числа только оттуда; (2) isolation: isolate на корне каждого компонента, чтобы внутренние z-index не протекали наружу; (3) оверлеи — в верхний слой: <dialog> с showModal() или Popover API. Верхний слой рисуется поверх всего документа, не участвует в войне z-index и сам управляет фокусом и ::backdrop.

Anchor positioning закрывает задачу, ради которой годами тянули Popper и Floating UI, — прицепить меню к кнопке и перевернуть его, когда не влезает:

.trigger { anchor-name: --menu-btn; }
.menu {
  position: fixed; position-anchor: --menu-btn;
  position-area: block-end span-inline-end;        /* снизу от кнопки, растёт вправо */
  position-try-fallbacks: flip-block, flip-inline; /* не влезло — переверни */
}
@supports not (anchor-name: --x) { /* фолбэк: relative-обёртка + absolute, как раньше */ }

Это Chromium-first API (Chrome/Edge с 125), остальные движки догоняют — сверяйтесь с caniuse и всегда пишите @supports-ветку.

8. Адаптивность: единицы, пороги и «внутренняя» адаптивность

Единицы — язык, которым вы объясняете браузеру, от чего зависит размер:

  • rem — всё, что масштабируется вместе с настройкой шрифта пользователя. Медиазапросы тоже пишите в rem: при зуме px-брейкпоинты не сдвигаются, и человек с зумом 200% получает десктопный макет в узком окне. em — только там, где размер зависит от локального шрифта: отступ иконки, padding кнопки. ch — колонка текста: max-inline-size: 65ch — проверенный предел читаемости.
  • % — от родителя, причём block-size: 100% работает лишь при заданной высоте родителя: процент от auto не вычисляется. vw — с оговоркой: 100vw не учитывает вертикальный скроллбар и на десктопе даёт горизонтальный скролл; берите 100% или 100cqw.
  • dvh / svh / lvh — динамическая, малая и большая высота вьюпорта. 100vh на мобильных равен lvh, поэтому «полноэкранный» блок уезжает под адресную строку. Правильный герой — 100svh (гарантированно видно) или 100dvh (подстраивается, но заставляет пересчитывать layout при скролле).
  • Дальше — плавные шкалы вместо ступенек: одна строка clamp() заменяет три медиазапроса.
:root {
  --step-0: clamp(1rem, 0.93rem + 0.34vw, 1.19rem);
  --step-1: clamp(1.25rem, 1.1rem + 0.75vw, 1.69rem);
  --gutter: clamp(1rem, 5vw, 3rem);
}
body { font-size: var(--step-0); }
h2   { font-size: var(--step-1); }
.section { padding-inline: var(--gutter); }

Средний аргумент clamp() обязан содержать rem-часть: чистые vw игнорируют пользовательский зум шрифта, а это прямое нарушение WCAG 1.4.4. Коэффициенты — линейная интерполяция между двумя ширинами вьюпорта; считать вручную не нужно, генератор шкал есть у Utopia.

Второй столп адаптивности — резервировать место заранее: почти каждый прыжок макета вызван контентом, чей размер выяснился поздно.

.media { aspect-ratio: 16 / 9; inline-size: 100%; object-fit: cover; }
/* и обязательно атрибуты в разметке: <img src="…" width="1600" height="900" alt="…"> */
.banner-slot { min-block-size: 6rem; }   /* место под асинхронный блок */
@font-face { font-family: Inter; src: url(/fonts/inter.woff2) format("woff2");
             font-display: swap; size-adjust: 107%; ascent-override: 90%; }  /* метрики ≈ метрики фолбэка */

Медиазапросы после этого остаются нужны для того, что действительно про устройство и человека:

@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
@media (pointer: coarse) { .btn { min-block-size: 44px; } }   /* палец, а не мышь */
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }

9. Контейнерные запросы: компонент смотрит на родителя

Медиазапрос знает только про вьюпорт. Но одна и та же карточка стоит и в узкой боковой колонке, и в широком гриде — и должна выглядеть по-разному. Годами это чинили модификаторами вроде card--compact, которые обязан был проставлять разработчик страницы: компонент не был самодостаточным. Теперь это решается в CSS:

/* 1. Обёртка объявляется контейнером. inline-size = «меряем ширину, высоту не трогаем» */
.card-slot { container-type: inline-size; container-name: card; }
.card { display: grid; gap: 1rem; }

/* 2. Компонент реагирует на ширину СВОЕЙ обёртки, а не окна */
@container card (inline-size >= 30rem) {
  .card { grid-template-columns: 12rem minmax(0, 1fr); align-items: start; }
  .card__title { font-size: 1.5rem; }
}

/* 3. Единицы контейнера: 1cqi = 1% инлайн-размера ближайшего контейнера */
.card__title { font-size: clamp(1rem, 5cqi, 2rem); }

Три вещи, которые нужно знать сразу:

  1. Контейнер не может запрашивать сам себя@container применяется к потомкам. Отсюда лишняя обёртка: честная цена инкапсуляции, а не недоработка.
  2. container-type: size требует известной высоты и изолирует обе оси: элемент перестаёт растягиваться по контенту, поэтому в 95% случаев нужен inline-size. Плюс container-type создаёт containing block для position: fixed внутри и включает contain: layout style — не вешайте его на body «на всякий случай».
  3. Style queries (@container style(--tone: danger)) реагируют на значение кастомного свойства — удобно для тем и вариантов, но поддержка пока ограничена custom properties. Экспериментальные scroll-state()-запросы («элемент сейчас прилип») уже появляются в Chromium — используйте только с @supports.

Разделение обязанностей простое: макет страницы — медиазапросы, поведение компонента — контейнерные запросы. Введение: «Say Hello to CSS Container Queries».

10. Почему вёрстка тормозит и как это измерять

Раскладка — отдельная фаза конвейера Style → Layout → Paint → Composite (детали в статье про браузер). Стоимость правки определяется тем, с какой фазы придётся начинать заново:

Что меняем Пересчёт начинается с Стоимость
inline-size, inset, font-size, display, вставка узла Layout высокая: O(число затронутых боксов)
color, background, box-shadow, border-radius Paint средняя
transform, opacity, filter на собственном слое Composite низкая, считает GPU

Отсюда правило анимаций: двигайте transform: translate3d(), а не inset-block-start; меняйте opacity, а не block-size + visibility. Раскрытие блока «до авто-высоты» делается без JS через трек грида:

.accordion { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 200ms; }
.accordion[open] { grid-template-rows: 1fr; }
.accordion > .body { overflow: hidden; min-block-size: 0; }

Layout thrashing — главный убийца отзывчивости. Браузер откладывает layout до конца кадра, но если JS читает геометрию (offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle) после того, как что-то записал, пересчёт происходит синхронно — и так на каждой итерации цикла.

// Плохо: forced synchronous layout на каждой итерации
for (const el of items) el.style.inlineSize = el.parentElement.offsetWidth / 2 + 'px';

// Хорошо: сначала все чтения, потом все записи
const half = container.offsetWidth / 2;
for (const el of items) el.style.inlineSize = `${half}px`;

// Ещё лучше: не опрашивать размеры вручную, а подписаться на изменения
new ResizeObserver(entries => {
  for (const e of entries) e.target.classList.toggle('is-narrow', e.contentRect.width < 320);
}).observe(document.querySelector('.card-slot'));

Полный список свойств и методов, вызывающих принудительный layout, ведёт Пол Айриш: What forces layout/reflow. Отдельная ловушка ResizeObserver — менять внутри колбэка размеры наблюдаемого элемента: получите бесконечный цикл и ошибку «ResizeObserver loop completed with undelivered notifications»; меняйте классы, а не геометрию. Ограничить область пересчёта помогает containment:

.widget  { contain: layout paint; }   /* внутренние изменения не влияют на внешнюю раскладку */
.comment { content-visibility: auto; contain-intrinsic-size: auto 140px; }  /* длинные списки */

content-visibility: auto на списке из тысяч элементов снимает сотни миллисекунд с первого рендера: браузер не считает layout того, что за экраном, но резервирует место по contain-intrinsic-size (ключевое слово auto запоминает реальный размер после первого показа). Обязательно проверьте поиск по странице, якорные ссылки и печать на реальном контенте.

Измерение. Сдвиги раскладки — метрика Core Web Vitals CLS: «хорошо» — 0.1 и меньше, считается не суммой всех сдвигов, а максимумом по сессионным окнам (окно до 5 с, разрыв 1 с).

// Диагностика в консоли: что именно двигается и на сколько
new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;   // сдвиги сразу после действия пользователя не считаются
    console.log('shift', entry.value.toFixed(4), entry.sources.map(s => s.node));
  }
}).observe({ type: 'layout-shift', buffered: true });

В проде считайте библиотекой web-vitals: она корректно реализует сессионные окна и отправку метрики при уходе со страницы. Инструменты, которые стоит открыть сегодня: Performance → запись загрузки (трек Layout Shifts с превью подвинувшихся элементов, фиолетовые блоки Layout и Recalculate Style); Rendering (три точки → More tools) → Layout Shift Regions, Paint flashing, Frame Rendering Stats; Elements → бейджи grid, flex и container включают оверлей с номерами линий и размерами треков (док по инспектору грида). Половина «магии» раскладки исчезает, как только видны реальные числа. Бюджеты, LCP и INP — в статье Производительность фронтенда.

11. Практикум: каркас приложения, который можно скопировать

Sticky-шапка, боковое меню, адаптивная сетка карточек и карточка на контейнерных запросах — всё из статьи в одном файле, работает без сборки. Разметка: .page с детьми header, nav, main, footer, внутри main.cards > .card-slot > .card.

:root { --gutter: clamp(1rem, 4vw, 2rem); --z-sticky: 100; }
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; line-height: 1.5; }
.page {
  display: grid; min-block-size: 100dvh; gap: 1rem;
  grid-template: "header header" auto "nav main" 1fr "footer footer" auto / 16rem minmax(0, 1fr);
}
.page > header { grid-area: header; position: sticky; inset-block-start: 0; z-index: var(--z-sticky); background: Canvas; }
.page > nav    { grid-area: nav; align-self: start; position: sticky; inset-block-start: 4rem; }
.page > main   { grid-area: main; padding-inline: var(--gutter); min-inline-size: 0; }
.page > footer { grid-area: footer; }

@media (width < 48rem) {   /* мобильный: одна колонка, меню под контентом */
  .page { grid-template: "header" auto "main" 1fr "nav" auto "footer" auto / minmax(0, 1fr); }
  .page > nav { position: static; }
}

.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); }
.card-slot { container-type: inline-size; container-name: card; }
.card { display: grid; gap: 0.5rem; padding: 1rem; border: 1px solid; border-radius: 0.5rem; isolation: isolate; }
.card__title { margin: 0; font-size: clamp(1rem, 4cqi, 1.5rem); text-wrap: balance; }
@container card (inline-size >= 28rem) { .card { grid-template-columns: 8rem minmax(0, 1fr); align-items: start; } }

12. Типичные ошибки

  1. block-size: 100vh на мобильных — блок уезжает под адресную строку; берите 100svh или 100dvh. И block-size: 100% без цепочки высот: процент от родителя с auto не вычисляется.
  2. Забытые min-inline-size: 0 и minmax(0, 1fr) — длинное слово, <pre> или таблица разрывают раскладку.
  3. position: absolute как основной инструмент вёрстки — не адаптируется и не участвует в потоке; абсолют для оверлеев и декора.
  4. position: fixed внутри предка с transform/filter/container-type — модалка прилипает не к вьюпорту, а к предку.
  5. Войны z-index — лечатся isolation: isolate, токенами слоёв и верхним слоем (<dialog>, popover), а не увеличением числа.
  6. Медиазапросы в px — ломают пользовательский зум. И order, row-reverse, grid-auto-flow: dense на интерактивных элементах — рассинхрон визуального и табового порядка.
  7. Вложенные гриды вместо subgrid — карточки в ряду перестают выравниваться при разной длине заголовков.
  8. overflow: hidden на предке ради обрезки — молча убивает sticky внутри; используйте overflow: clip.
  9. Анимация inline-size/inset вместо transform, километры margin-block-end с :last-child вместо gap и правка раскладки «на глаз» без оверлеев grid/flex в DevTools.

Мини-итог

  • Раскладка — ответ на два вопроса: сколько места даёт родитель и сколько хочет контент; min-content / max-content / fit-content — словарь этого разговора. Поток при этом не «устарел»: flow-root создаёт BFC, align-content центрирует и без flex.
  • Flexbox одномерен и отдаёт инициативу контенту; grid двумерен и отдаёт её контейнеру. flex: 1 — равные элементы, flex: auto — пропорциональные содержимому. min-inline-size: 0 и minmax(0, 1fr) — противоядие от переполнения в обоих алгоритмах.
  • repeat(auto-fit, minmax(min(16rem, 100%), 1fr)) даёт адаптивную сетку без медиазапросов; subgrid выравнивает внутренности независимых компонентов.
  • position ломается предсказуемо: смотрите на containing block и контекст наложения, оверлеи выносите в верхний слой. Единицы: rem для порогов и типографики, ch для колонки текста, svh/dvh вместо vh, cqi внутри контейнеров, clamp() вместо ступенек. Контейнерные запросы делают компоненты переносимыми, медиазапросы остаются для устройства и предпочтений человека.
  • Производительность раскладки измеряется, а не угадывается: CLS, трек Layout, PerformanceObserver, пакетирование чтений и записей, contain и content-visibility.

Источники

Что дальше

Раскладку построили — остаётся вопрос, как организовать сами стили, чтобы через полгода их можно было менять без страха: именование, изоляция, токены, утилитарные классы и дизайн-системы.

Архитектура стилей: БЭМ, CSS-modules, CSS-in-JS, Tailwind, дизайн-системы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов