Вёрстка: 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 перестанет удивлять:
- Каждому элементу считается базис —
flex-basis. Приautoберётсяinline-size, а если и онauto— размер по контенту (max-content, ограниченный доступным местом). - Базисы складываются и сравниваются с внутренним размером контейнера. Сумма меньше — есть свободное место, работает
flex-grow; больше — дефицит, работаетflex-shrink. - Свободное место делится пропорционально коэффициентам
flex-grow. Дефицит — пропорционально произведению «flex-shrink× базис», поэтому при нехватке места крупный элемент ужимается сильнее мелкого. Асимметрия неочевидна, но логична: сжать на 50% элемент в 40px и элемент в 400px — разный ущерб. - Если место осталось (никто не растёт), его распределяет
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 — раскладку диктует контейнер, а не сумма желаний детей.
.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); }
Три вещи, которые нужно знать сразу:
- Контейнер не может запрашивать сам себя —
@containerприменяется к потомкам. Отсюда лишняя обёртка: честная цена инкапсуляции, а не недоработка. container-type: sizeтребует известной высоты и изолирует обе оси: элемент перестаёт растягиваться по контенту, поэтому в 95% случаев нуженinline-size. Плюсcontainer-typeсоздаёт containing block дляposition: fixedвнутри и включаетcontain: layout style— не вешайте его наbody«на всякий случай».- 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. Типичные ошибки
block-size: 100vhна мобильных — блок уезжает под адресную строку; берите100svhили100dvh. Иblock-size: 100%без цепочки высот: процент от родителя сautoне вычисляется.- Забытые
min-inline-size: 0иminmax(0, 1fr)— длинное слово,<pre>или таблица разрывают раскладку. position: absoluteкак основной инструмент вёрстки — не адаптируется и не участвует в потоке; абсолют для оверлеев и декора.position: fixedвнутри предка сtransform/filter/container-type— модалка прилипает не к вьюпорту, а к предку.- Войны
z-index— лечатсяisolation: isolate, токенами слоёв и верхним слоем (<dialog>, popover), а не увеличением числа. - Медиазапросы в
px— ломают пользовательский зум. Иorder,row-reverse,grid-auto-flow: denseна интерактивных элементах — рассинхрон визуального и табового порядка. - Вложенные гриды вместо
subgrid— карточки в ряду перестают выравниваться при разной длине заголовков. overflow: hiddenна предке ради обрезки — молча убиваетstickyвнутри; используйтеoverflow: clip.- Анимация
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.
Источники
- MDN: CSS flexible box layout и MDN: CSS grid layout — справочник первой инстанции.
- CSS Grid Layout Level 2 и CSS Box Alignment Level 3 — спецификации, включая subgrid и общую модель выравнивания.
- Every Layout — Heydon Pickering и Andy Bell: раскладки, которые адаптируются сами; Rachel Andrew, Grid by Example — рецепты от редактора спецификации.
- Josh Comeau: интерактивный гид по flexbox и по grid.
- Ahmad Shadeed — разборы контейнерных запросов, логических свойств,
:has(). - web.dev: Learn CSS — Layout, Optimize CLS и Utopia — генератор плавных шкал на
clamp().
Что дальше
Раскладку построили — остаётся вопрос, как организовать сами стили, чтобы через полгода их можно было менять без страха: именование, изоляция, токены, утилитарные классы и дизайн-системы.
Архитектура стилей: БЭМ, CSS-modules, CSS-in-JS, Tailwind, дизайн-системы