Контейнерные запросы

Адаптивность по ширине контейнера, а не по ширине окна. Тот же строй классов, та же шкала брейкпоинтов, разница в одну букву: -md смотрит на окно, -cmd — на ближайшего предка с .gr-cq.

Ядро + утилиты + компоненты packages/core/scss/_container-queries.scss

Зачем

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

container-type: inline-size включает инлайн-containment. Ширина элемента перестаёт зависеть от его содержимого. У inline-block, у float, у элемента с width: fit-content она схлопывается в ноль, и содержимое уезжает наружу. Ставьте .gr-cq на блок, ширину которого задаёт раскладка: на колонку сетки, на карточку, на body.

По той же причине библиотека намеренно не объявляет контейнером body за вас. Иначе каждый безымянный @container на странице разрешался бы к окну вместо того, чтобы просто не сработать, — тихий неверный ответ вместо громкого отказа.

Объявление контейнера

КлассCSSНазначение
.gr-cqcontainer-type: inline-size
container-name: var(--gr-name, none)
Элемент становится контейнером запроса по инлайн-оси
--gr-name@property, inherits: falseИмя контейнера, если нужно обратиться к нему через голову ближайшего
.gr-cq-page, .gr-cq-main, .gr-cq-aside, .gr-cq-cardcontainer: page / inline-size и т.д.Контейнер с именем классом — четыре слота раскладки как соглашение библиотеки (см. «Виджеты по гнезду»)
<div class="gr-cq">
  <article class="gr-p-3 gr-p-5-cmd">…</article>
</div>

Суффикс -c<брейкпоинт>

Шкала — та же карта $gr-breakpoints, что и у оконных запросов. Отдельной шкалы для контейнеров нет намеренно: она удвоила бы источник правды.

СуффиксЗапросЧитается как
-csm@container (width >= 640px)от 640px ширины контейнера
-cmd@container (width >= 768px)от 768px ширины контейнера
-clg@container (width >= 1024px)от 1024px ширины контейнера
-cxl@container (width >= 1280px)от 1280px ширины контейнера

Шкала общая — и потому крупная. 768px ширины контейнера — это много: карточка в трёхколоночной сетке на экране 1440px имеет около 440px, в двухколоночной — около 680px. Реально пересекаемый порог для блока внутри страницы — -csm (640px); -clg и -cxl достаются разве что основной колонке макета.

Отдельную, более мелкую шкалу для контейнеров библиотека не заводит намеренно: это удвоило бы источник правды ровно там, где он был сведён в одну карту. Нужен свой порог — он пишется одной строкой своего CSS: @container (width >= 26rem) { … }.

Какие классы получили контейнерные варианты

МодульПримеры
Явные сетки (ядро).gr-grid-1-csm.gr-grid-12-cxl — число колонок по ширине гнезда
Flex (ядро).gr-flex-row-cmd, .gr-flex-col-csm, .gr-flex-wrap-clg — направление и перенос; выравниваний нет
Таблица (компоненты).gr-table-stack-cmd — карточки, пока гнездо уже порога
Лента (griffinjs.css).gr-track-3-cmd — слайдов в ряд, .gr-track-stack-csm — столбик, пока гнездо уже порога
Spacing.gr-p-4-cmd, .gr-px-8-clg, .gr-my-6-csm, .gr-mx-auto-cxl
Sizing.gr-w-full-cmd, .gr-w-1/2-clg, .gr-h-auto-csm
Typography.gr-text-lg-csm, .gr-font-bold-clg, .gr-text-center-csm, .gr-truncate-cxl
Visibility.gr-hidden-cmd, .gr-block-clg, .gr-inline-block-csm

Остальные модули контейнерных вариантов не получили. Цвет, тень, граница, позиционирование, прозрачность и переходы от ширины контейнера не зависят по смыслу, а каждый селектор — это байты в файле, который скачивает каждый. Перечень модулей проверяется тестами (packages/utils/test/utils.test.js для утилит, packages/core/test/container.test.js для ядра), так что расшириться молча он не может.

Логические отступы .gr-ms-*, .gr-me-*, .gr-ps-*, .gr-pe-* несут оба вида суффиксов наравне с осевыми — .gr-ms-4-md, .gr-ms-4-cmd: после удаления физических односторонних классов именно они дают адаптивный односторонний отступ.

Попробуйте: потяните за правый нижний угол

Ширина окна не меняется — меняется ширина контейнера
16 : 9

Карточка статьи

Узкий контейнер — картинка сверху, текст под ней. От 640px ширины контейнера — картинка слева, текст справа, и кегль заголовка на ступень крупнее.

Эта строка появляется только в широком контейнере (.gr-hidden .gr-block-csm).

<div class="gr-cq">
  <article data-gr-layout="a1-b1" data-gr-layout-csm="a1b2">
    <div class="gr-area-a">…</div>
    <div class="gr-area-b">
      <h3 class="gr-text-base gr-text-lg-csm">…</h3>
    </div>
  </article>
</div>

Одна разметка, два контейнера

Ширина окна у обеих карточек ниже одна и та же. Различает их только ширина колонки, в которой они стоят.

Слева 15rem, справа — остаток строки (порог 640px, нужно широкое окно)
16 : 9

Узкая колонка

Контейнер уже 640px — раскладка вертикальная.

16 : 9

Широкая колонка

От 640px контейнера — картинка слева, текст справа. Сузьте окно, и обе карточки станут вертикальными.

Виджеты по гнезду

Виджет магазина — подборка товаров, промо, таймер — монтируют в любую позицию раскладки: в боковую колонку, в содержимое, в карточку товара. Его вид должен зависеть от ширины гнезда, а не окна, и его CSS должен уметь обратиться к гнезду по имени через голову ближайшего безымянного .gr-cq. Имя классом, а не инлайновым style="--gr-name: aside": инлайновый стиль в теме обычно запрещён. Четыре имени — соглашение, чтобы тема и модули договаривались об одних словах:

КлассИмяКто это
.gr-cq-pagepageСтраница целиком — обёртка макета
.gr-cq-mainmainСодержимое — основная колонка
.gr-cq-asideasideБоковая колонка
.gr-cq-cardcardКарточка — товар, пост, панель
<aside class="gr-cq-aside">
  <section class="promo">…</section>
</aside>

/* CSS виджета: порог по гнезду, а не по окну */
@container aside (width < 40rem) { .promo { … } }

Пороги — те же, что у суффиксов: -csm / -cmd / -clg / -cxl = 640 / 768 / 1024 / 1280 px ширины гнезда. Так виджет собирается из готовых классов без своего CSS: сетка .gr-grid-1 .gr-grid-3-cmd, флекс .gr-flex-col .gr-flex-row-csm, таблица .gr-table-stack-cmd, лента .gr-track-stack-csm .gr-track-3-cmd — столбик в узкой колонке, три в ряд в широкой. Своё имя по-прежнему через --gr-name; вариант data-gr-cq="…" не сделан — attr() внутри container-name не поддерживается ни одним движком.

Лента в двух гнёздах при одной ширине окна: сверху 14rem, снизу вся ширина колонки
Кресло «Дюна»
Торшер «Свет»
Полка «Норд»
Кресло «Дюна»
Торшер «Свет»
Полка «Норд»
Стол «Лофт»

Раскладки: data-gr-layout-c<брейкпоинт>

АтрибутКогда действует
data-gr-layoutБазовая раскладка — ниже первого объявленного диапазона
data-gr-layout-md@media: от 768px ширины окна
data-gr-layout-cmd@container: от 768px ширины контейнера

Контейнер должен быть предком, а не самим элементом. @container ищет ближайшего предка с container-type и никогда не спрашивает сам элемент: элемент не может измерять себя. Поэтому .gr-cq ставится на обёртку, а data-gr-layout-cmd — на то, что внутри неё. Рантайм не проставляет .gr-cq сам: инлайн-containment, навешенный на чужую разметку без спроса, ломает страницу молча и в неожиданном месте.

Оконная и контейнерная цепочки на одном элементе независимы — и не мешают друг другу. Базовая раскладка при этом закрывается обеими сразу:

<div data-gr-layout="a1" data-gr-layout-md="a1b1" data-gr-layout-cmd="a2b1">
@media (width < 768px) {
  @container (width < 768px) { .gr-l-хеш { /* a1 */ } }
}
@media (width >= 768px)     { .gr-l-хеш { /* a1b1 */ } }
@container (width >= 768px) { .gr-l-хеш { /* a2b1 */ } }

Оставь база открытой по одной из осей — она накрыла бы диапазон другой, и какая раскладка победит, решал бы порядок правил в таблице, а не ширина. Когда оба диапазона совпали, побеждает контейнерный: его правило стоит в таблице позже.

Вложенные контейнеры

Запрос без имени разрешается к ближайшему предку-контейнеру. Вложили .gr-cq внутрь .gr-cq — всё внутри считается от внутреннего, а внешний становится недосягаем. Это самая частая ошибка с контейнерными запросами, и она не даёт ни ошибки в консоли, ни пустого стиля: просто отвечает не тот элемент.

<section class="gr-cq">              <!-- внешний: 1200px -->
  <div class="gr-cq">                <!-- внутренний: 300px -->
    <p class="gr-text-lg-csm">…</p>  <!-- считает 300px, а не 1200px -->
  </div>
</section>

Чтобы обратиться к внешнему через голову внутреннего, дайте ему имя. Имя приезжает свойством --gr-name, а не классом: имя контейнера — это CSS-идентификатор, придуманный на стороне страницы, и статический файл не может знать его список. Свойство общее, а не --gr-cq-name: container-name, anchor-name и view-transition-name берут один и тот же идентификатор, и элемент вправе называться один раз на все роли.

<section class="gr-cq" style="--gr-name: page">
  <div class="gr-cq">
    <p class="wide-only">…</p>
  </div>
</section>
/* своё правило страницы */
@container page (width >= 60rem) {
  .wide-only { display: block; }
}

--gr-name зарегистрировано через @property с inherits: false. Наследуйся оно — вложенный .gr-cq перенял бы имя предка, и запрос @container page нашёл бы внутренний контейнер вместо внешнего. Та же причина, по которой ненаследуемы --gr-r и --gr-p в каскаде скруглений.

Из SCSS

Обёртка запроса — миксин gr-container-media:

@use 'griffincss-core/scss/container-queries' as cq;

@include cq.gr-container-media(md) {
  .my-card { padding: 2rem; }
}

// именованный контейнер
@include cq.gr-container-media(lg, page) {
  .my-card { padding: 3rem; }
}

Компилтайм-раскладки принимают контейнерные варианты теми же параметрами $csm$cxl. Вывод побайтово совпадает с тем, что генерирует рантайм, — это проверяется в packages/core/test/generate.test.js:

@use 'griffincss-core/scss/grid-parser' as p;

@include p.gr-grid-layout('a1', $md: 'a1b1', $cmd: 'a2b1', $name: 'hero');

Поддержка

Контейнерные запросы — базовая возможность целевых браузеров с 2023 года (Chrome 105+, Safari 16+, Firefox 110+). Развилки @supports библиотека не делает: она и так требует :has() и popover, которые новее, и удвоение вывода ради среды, которая не поддерживается в остальном, ничего бы не купило.

Примеры использования

Контейнерные варианты пишутся один раз — а дальше виджет носит свою адаптивность с собой, в какую бы клетку шаблона его ни поставили. Два рецепта ниже — карточка, меняющая раскладку и словоохотливость по месту, и KPI-плитка, меняющая плотность.

Карточка товара для любой клетки

Одна разметка на все места шаблона: в узкой клетке карточка вертикальна и немногословна, в широкой — раскладывается картинкой влево (data-gr-layout-csm), укрупняет заголовок (.gr-text-lg-csm) и показывает описание, которого в узкой версии нет вовсе: .gr-hidden .gr-block-csm — скрытие тоже меряется контейнером. Обёртка-клетка получает .gr-cq, сама карточка — нет: элемент не может измерять себя.

<div class="gr-cq"><!-- клетка шаблона любой ширины -->
  <article class="gr-card gr-p-4" data-gr-layout="a1-b1" data-gr-layout-csm="a1b2">
    <img class="gr-area-a gr-w-full gr-aspect-video gr-object-cover gr-radius-1" src="…" alt="">
    <div class="gr-area-b">
      <h3 class="gr-text-base gr-text-lg-csm">Кресло «Дюна»</h3>
      <p class="gr-hidden gr-block-csm gr-text-sm gr-text-ink-secondary">
        Описание видно только в широкой клетке.
      </p>
      <p class="gr-font-bold">12 400 ₽</p>
    </div>
  </article>
</div>
Клетки разной ширины — карточка одна (порог 640px контейнера, нужно широкое окно)
Фото

Кресло «Дюна»

Ротанг, съёмные подушки, каркас из бука — описание видно только в широкой клетке.

12 400 ₽

Фото

Кресло «Дюна»

Ротанг, съёмные подушки, каркас из бука — описание видно только в широкой клетке.

12 400 ₽

KPI-плитка: плотность по ширине места

Виджет статистики живёт и в тесном сайдбаре, и в широкой клетке дашборда. Контейнерные варианты спейсинга и типографики подстраивают плотность: в узком месте — .gr-p-3 и обычный кегль числа, от 640px контейнера — просторный .gr-p-6-csm, крупное число .gr-text-4xl-csm и центрирование .gr-text-center-csm. Медиазапросами такое не решается: ширина окна одна, а клетки — разные.

<div class="gr-cq">
  <div class="gr-card gr-p-3 gr-p-6-csm gr-text-center-csm">
    <p class="gr-text-xs gr-uppercase gr-text-ink-secondary">Выручка</p>
    <p class="gr-text-2xl gr-text-4xl-csm gr-font-extrabold">1,2 млн ₽</p>
  </div>
</div>
Одна плитка: сверху узкое место, ниже — широкое (порог 640px, нужно широкое окно)

Выручка

1,2 млн ₽

Выручка

1,2 млн ₽