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

Такое выравнивание по-честному решается одним значением — subgrid. Разберём его с самого начала, медленно и с примером на каждый шаг. Из подготовки нужно только понимать, что такое display: grid и grid-template-columns.

Почему вложенная сетка не видит родителя

Типичная разметка карточки: заголовок, автор, описание, кнопка. Карточки лежат в сетке, сами карточки — флекс-колонки:

<div class="cards">
  <article class="card">
    <h3>Флексбокс</h3>
    <p class="author">Анна</p>
    <p class="text">Раскладка в одну линию.</p>
    <a class="btn" href="#">Читать</a>
  </article>
  <!-- ещё две карточки -->
</div>
.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

Сетка .cards отвечает только за то, где стоят сами карточки. Что внутри карточки, она не знает и знать не может: заголовок, автор и кнопка — это «внуки», а сетка раскладывает только прямых детей. Каждая карточка считает высоту своих частей сама, по своему содержимому, и никак не договаривается с соседками.

Если сделать карточку тоже гридом (display: grid), ничего не изменится: получится отдельная сетка со своими строками, которая про строки соседней карточки так же ничего не знает. Высота 100% или фиксированная высота заголовка тоже не помогают — первое растягивает карточку целиком, но не её части, второе ломается, как только текст станет длиннее.

Что делает subgrid

Одним предложением: элемент с subgrid не рисует свои линии сетки, а берёт линии родителя.

Удобно представить тетрадь в клетку. Обычная вложенная сетка — это вклеенный листок с собственной разлиновкой: клетки на нём другого размера и не совпадают с клетками тетради. Subgrid — это просто участок той же страницы, обведённый рамкой: клетки внутри рамки те же самые, что и снаружи.

Самый маленький пример. У родителя четыре колонки разной ширины и два ребёнка, занимающих колонки со 2-й по 4-ю:

.parent {
  display: grid;
  grid-template-columns: 80px 1fr 2fr 120px;
  gap: 8px;
}

.child {
  grid-column: 2 / 5;              /* занимаем три колонки родителя */
  display: grid;
}

Если первому написать grid-template-columns: repeat(3, 1fr), он поделит своё место на три равные части, и ни одна граница не совпадёт с родительской. Второму зададим grid-template-columns: subgrid и теперь его три колонки будут: 1fr, 2fr и 120px. Его дети встанут точно под колонками родителя.

Рецепт из трёх шагов

Чтобы subgrid заработал, нужны три вещи одновременно:

  1. Родитель — грид. Обычный display: grid, у него есть колонки и/или строки.
  2. Ребёнок занимает несколько треков родителя. Через grid-column или grid-row: 2 / 5, 1 / -1, span 4 — как удобнее. Трек — это одна колонка или одна строка сетки.
  3. Ребёнок тоже грид и говорит subgrid по нужной оси: grid-template-columns: subgrid для колонок, grid-template-rows: subgrid для строк.
.parent {
  display: grid;                   /* шаг 1 */
}

.child {
  grid-row: span 4;                /* шаг 2: сколько строк родителя берём */
  display: grid;                   /* шаг 3 */
  grid-template-rows: subgrid;     /* шаг 3 */
}

Главная ловушка — забыть про span

Если пропустить второй шаг, ребёнок по умолчанию займёт один трек родителя. Подсетка получит одну строку, и все дети попытаются в неё влезть. Новых строк subgrid по своей оси не создаёт, поэтому лишние элементы не переносятся вниз, а ложатся друг на друга:

/* сломано: заголовок, автор, текст и кнопка — в одной клетке */
.card {
  display: grid;
  grid-template-rows: subgrid;
}

/* починено: карточка занимает 4 строки — по одной на каждую часть */
.card {
  grid-row: span 4;
  display: grid;
  grid-template-rows: subgrid;
}

Число в span должно совпадать с числом частей внутри. Добавили в карточку пятый элемент, например бейдж, — меняем на span 5, иначе бейдж наедет на кнопку.

Карточки с общими строками

Возвращаемся к карточкам из начала. Меняем флекс на подсетку по строкам:

.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.card {
  grid-row: span 4;              /* заголовок, автор, текст, кнопка */
  display: grid;
  grid-template-rows: subgrid;   /* строки — общие на весь ряд */
}

Важная деталь: у .cards нет grid-template-rows. Он и не нужен: карточка просит четыре строки, и родитель создаёт их автоматически. Высота каждой строки считается по самому высокому элементу в ней во всём ряду карточек. Самый длинный заголовок задаёт высоту строки заголовков, самое длинное описание — строки описаний, и кнопки встают на одну линию.

Во втором ряду карточек будет своя четвёрка строк со своими высотами, первый ряд на него не влияет.

Форма: подписи и поля в две ровные колонки

Вторая классика — форма, где подписи слева, поля справа, и все поля начинаются с одной вертикали. Каждую пару «подпись + поле» обычно оборачивают в div: туда же потом приедут подсказка, текст ошибки, фон при фокусе.

<form class="form">
  <div class="field">
    <label for="name">Имя</label>
    <input id="name" type="text">
  </div>
  <div class="field">
    <label for="email">Электронная почта</label>
    <input id="email" type="email">
    <p class="hint">Пришлём только подтверждение заказа</p>
  </div>
</form>

Без subgrid у каждой обёртки своя ширина колонки подписи, и короткое «Имя» сдвигает своё поле левее, чем длинная «Электронная почта». С subgrid ширина колонки подписей одна на всю форму:

.form {
  display: grid;
  grid-template-columns: max-content 1fr;  /* подписи — по самой длинной */
  gap: 12px 16px;
}

.field {
  grid-column: 1 / -1;            /* обёртка на обе колонки */
  display: grid;
  grid-template-columns: subgrid;
}

.hint {
  grid-column: 2;                 /* подсказка — строго под полем */
}

Значение max-content делает первую колонку ровно по ширине самой длинной подписи — подробнее про такие размеры в статье про min-content, max-content и fit-content. Подсказка с grid-column: 2 встаёт во вторую колонку на новой строке внутри обёртки — ровно под полем.

Похожий эффект можно получить, если убрать обёртки совсем или повесить на них display: contents. Но тогда у обёртки пропадает собственный блок: ей уже не задашь фон, рамку и отступы. Subgrid оставляет обёртку настоящим элементом.

Список «значок — текст — дата» как таблица без таблицы

Лента уведомлений, история заказов, список файлов: слева метка, в центре текст, справа дата. Хочется, чтобы метки были одной ширины, а даты стояли ровным столбиком — как в таблице. Но это не табличные данные, и <table> здесь был бы не к месту.

<ul class="feed">
  <li>
    <span class="badge">PR</span>
    <span>Коллега одобрил изменения в ветке с формой</span>
    <time>5 мин назад</time>
  </li>
  <li>
    <span class="badge">Release</span>
    <span>Вышла версия 2.4 с тёмной темой</span>
    <time>2 недели назад</time>
  </li>
</ul>
.feed {
  display: grid;
  grid-template-columns: auto 1fr auto;  /* метка · текст · дата */
  column-gap: 12px;
  list-style: none;
}

.feed li {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #e2e8f0;
}

Колонки с auto подстраиваются под самое широкое содержимое во всех строках сразу: самая длинная метка («Release») задаёт ширину первой колонки, самая длинная дата — третьей. При этом каждый <li> остаётся отдельным блоком с отступами и разделителем, и разметка остаётся семантичным списком.

Внутренний отступ padding у подсетки работает как обычно: крайние элементы просто отодвигаются от её краёв, на линии сетки это не влияет.

Ещё несколько возможностей

Subgrid сразу по двум осям

Ничто не мешает взять у родителя и колонки, и строки. Например, на дашборде из клеток 4×3 выделенный блок занимает квадрат 2×2, а его содержимое ложится ровно в те же клетки, что и соседние карточки:

.board {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(3, 80px);
  gap: 12px;
}

.featured {
  grid-column: 2 / 4;
  grid-row: 1 / 3;
  display: grid;
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}

У .featured внутри четыре клетки, и они совпадают с клетками всей доски — вместе с промежутками между ними.

Subgrid внутри subgrid

Подсетку можно вложить в подсетку — линии передаются вниз по цепочке. Пример: меню кофейни, где есть группы, а в группах строки. Колонки «название / объём / цена» должны совпадать во всех группах:

.menu {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 8px 24px;
}

/* уровень 1: группа берёт колонки меню */
.group {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
}

.group h3 {
  grid-column: 1 / -1;
}

/* уровень 2: строка берёт колонки группы — а значит, и меню */
.row {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
}

Даже если самое длинное значение объёма («1 000 мл») живёт в группе «Чай», колонка объёма в группе «Кофе» будет той же ширины. Обе группы смотрят на одну и ту же сетку .menu.

Свой gap внутри подсетки

Промежутки между треками подсетка по умолчанию берёт у родителя. В карточках это часто мешает: между рядами карточек нужно 32px воздуха, а между заголовком и автором внутри карточки — 6px. Решение — задать подсетке свой row-gap:

.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px 16px;          /* 32px между рядами карточек */
}

.card {
  grid-row: span 4;
  display: grid;
  grid-template-rows: subgrid;
  row-gap: 6px;            /* внутри карточки плотнее */
}

Строки при этом остаются общими: свой gap меняет только расстояние между частями внутри подсетки, но не её выравнивание с соседями.

Имена линий доходят до подсетки

Если у линий родителя есть имена, внутри подсетки ими можно пользоваться напрямую. Самый практичный случай — раскладка статьи: текст в узкой колонке посередине, а картинки или цитаты при желании на всю ширину экрана.

.page {
  display: grid;
  grid-template-columns:
    [full-start] minmax(16px, 1fr)
    [content-start] minmax(0, 520px)
    [content-end] minmax(16px, 1fr)
    [full-end];
}

.article {
  grid-column: full;               /* статья на всю ширину... */
  display: grid;
  grid-template-columns: subgrid;  /* ...но с линиями родителя */
}

.article > * {
  grid-column: content;            /* имя пришло от .page */
}

.article > .wide {
  grid-column: full;
}

Пара линий content-start / content-end автоматически даёт область с именем content, поэтому достаточно написать grid-column: content. Без subgrid внутри .article этих имён бы не было, и колонки пришлось бы описывать заново. Подсетка может добавить и собственные имена линий, например grid-template-columns: subgrid [a] [b], — они просто дополнят родительские.

Частые ошибки

  • Нет span или он меньше числа частей. Подсетка получает слишком мало треков, и лишние элементы наползают друг на друга в последнем. Считаем детей и ставим столько же.
  • Забыли display: grid у ребёнка. Значение subgrid работает только у элемента, который сам является грид-контейнером.
  • Ребёнок не прямой потомок сетки. Subgrid передаёт линии только на один уровень вниз. Если между .cards и .card затесалась обёртка, например ссылка вокруг карточки, — подсеткой должна стать и обёртка, или нужно убрать её из цепочки.
  • Ждут выравнивания между рядами. Карточки первого ряда выравниваются друг с другом, второго — друг с другом, но строки первого ряда не влияют на второй. Это поведение по задумке, а не баг.

Если браузер не знает subgrid

Для браузеров, которые понимают только обычный грид, удобно сделать базовую раскладку на флексе, а подсетку включать через @supports (подробно это правило разобрано в статье про at-правила CSS):

/* база: флекс, описание растягивается и прижимает кнопку к низу */
.card {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.card .text {
  flex: 1;
}

/* улучшение: если браузер знает subgrid */
@supports (grid-template-rows: subgrid) {
  .card {
    display: grid;
    grid-row: span 4;
    grid-template-rows: subgrid;
  }
}

Карточки в ряду и так одной высоты (грид их растягивает), а flex: 1 у описания съедает свободное место и уводит кнопку вниз. Заголовки в старом браузере не выровняются, но кнопки встанут в линию — для фолбэка этого достаточно. В браузере с поддержкой сработает блок внутри @supports, а flex: 1 в гриде просто ни на что не влияет.

Поддержка браузерами
chrome
Chrome
117
firefox
Firefox
71
edge
Edge
117
safari
Safari
16
opera
Opera
103

Коротко

  • Subgrid нужен, когда элементы внутри разных блоков должны стоять на общих линиях: части карточек, поля формы, столбцы списка.
  • Три шага: родитель — грид; ребёнок занимает нужное число треков; ребёнок — грид с grid-template-columns: subgrid и/или grid-template-rows: subgrid.
  • Без span подсетка получает один трек — самая частая причина, почему «ничего не работает».
  • Промежутки и имена линий приходят от родителя; gap можно переопределить, имена — дополнить.
  • Подсетки вкладываются друг в друга, линии передаются по цепочке.

Полный справочник по значению с деталями по каждому случаю — на MDN.