Недавно я работал над современной реализацией блогролла (перечня внешних полезных/интересных блогов). Замысел был в том, чтобы предоставить читателям подборку из последних постов в этих блогах, упакованную в журнальную вёрстку, а не сухой список ссылок в сайдбаре.

Самая простая часть задачи — получение списка постов и их эксцерптов (эксцерпт — вступительный текст до ката) с наших любимых RSS–фидов. Для этого мы воспользовались WordPress-плагином Feedzy lite, который умеет агрегировать несколько фидов в один список, отсортированный по времени — идеальное решение в нашем случае. Трудная же часть в том, чтобы сделать всё красиво.

Стандартный интерфейс списка у плагина, пожалуй, безвкусный, так что я захотел стилизовать его под сайт газеты или журнала со смесью больших и маленьких «избранных» блоков.

Звучит как идеальный случай, чтобы воспользоваться CSS Grid! Создаём Grid Layout для разных макетов, скажем, один пятиколоночный и один трёхколоночный, а затем переключаемся между ними с помощью медиа-запросов на разных размерах экрана. Верно? Но нужны ли нам на самом деле эти медиа-запросы, и вся эта морока с определением контрольных точек, если можно просто использовать параметр Grid auto-fit, который сделает адаптивную сетку за нас?

Эта идея мне показалась заманчивой, но когда я начал добавлять элементы, охватывающие несколько колонок сетки (спаны), сетка начала вылезать за пределы страницы на узких экранах. Медиа-запросы казались единственным решением. Но я нашёл кое-что получше!

Изучив некоторое количество статей по CSS Grid, я обнаружил, что они в основном делятся на два типа:

  1. Как создать интересный макет со спанами, но с заданным количеством колонок.
  2. Как создать адаптивный макет на Grid, но с колонками одинаковой ширины (т.е. без спанов).

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

Прелесть в том, что как только начинаешь понимать ограничения адаптивных сеток и почему и когда спаны ломают адаптивность, нетрудно создать журнальный макет в несколько десятков строк кода и один медиа-запрос (или вообще без них, если хочешь ограничить разнообразие спанов).

Вот наглядное сравнение RSS плагина из коробки и результата нашей работы (кликабельно):


Это полностью адаптивный журнальный макет с цветными «избранными» блоками, которые динамически подстраиваются под макет в зависимости от количества колонок. Страница отображает около 50 постов, но код макета не зависит от количества элементов. Можно запросто увеличить количество постов до 100 в настройках плагина, и макет останется интересным до самого низа.

Всё это достигается исключительно за счёт CSS и с использованием всего одного медиа-запроса для отображения контента в одну колонку на самых узких экранах (меньше 460 пикс.).

Что самое невероятное, на весь макет понадобилось всего 21 строка CSS (не считая общих стилей сайта). Однако, чтобы достичь такой гибкости, используя так мало кода, мне пришлось глубоко погрузиться в самые тёмные глубины CSS Grid и узнать как обойти некоторые его ограничения.

Код, на котором держится весь макет, невероятно короток, и всё благодаря великолепию CSS Grid:

.archive {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  grid-gap: 32px;
  grid-auto-flow: dense;
}

/* Широкие блоки постов */
.article:nth-child(31n + 1) {
  grid-column: 1 / -1;
}
.article:nth-child(16n + 2) {
  grid-column: -3 / -1;
}
.article:nth-child(16n + 10) {
  grid-column: 1 / -2;
}

/* Одноколоночное отображение на мобильных */
@media (max-width: 459px) {
  .archive {
    display: flex;
    flex-direction: column;
  }
}

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

Создание адаптивной сетки


Я создал 17 элементов для демонстрации всего разнообразия будущего контента — заголовки, картинки, и эксцерпты, и обернул в <div></div>

<div class="archive">
  <article class="article">
    <!-- контент -->
  </article>

  <!-- ещё 16 статей -->

</div>

Код для превращения этих элементов в адаптивную сетку особенно компактен:

.archive {
  /* Объявляем элемент в качестве контейнера-сетки */
  display: grid;
  /* Автоматически уместить как можно больше элементов в строке, не переходя нижнюю границу ширины в 180 пикс. */
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  /* Небольшие отступы между постами */
  grid-gap: 1em;
}

> Демо на CodePen

Обратите внимание, как высота строк автоматически подстраивается под самый высокий блок контента в строке. Если вы измените ширину в демо по ссылке выше, вы увидите, что элементы увеличиваются и уменьшаются автоматически и количество колонок меняется от 1 до 5, соответственно.

Здесь в действии мы видим магию CSS Grid под названием auto-fit. Это ключевое слово работает совместно с функцией minmax(), применённой к grid-template-columns.

Как это работает


Сам по себе пятиколоночный макет можно получить вот так:

.archive {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
}

Однако, таким образом создаётся пятиколоночный макет, который растягивается и сжимается на разной ширине экрана, но при этом всегда остаётся пятиколоночным, что приводит к ужасно узким колонкам на маленьких экранах. Первым в голову приходит написать кучку медиа-запросов и переопределить сетку с разным количеством колонок. Это бы сработало, но ключевое слово auto-fit делает всё это автоматически.

Чтобы auto-fit так, как нам нужно, необходимо воспользоваться функцией minmax(). Таким образом мы как бы говорим браузеру, насколько сильно можно сжать колонки, и насколько сильно растянуть. При достижении одной из границ, количество колонок возрастает или уменьшается соответственно.

.archive {
  grid-template-columns: repeat (auto-fit, minmax(180px, 1fr));
}

В этом примере браузер будет пытаться вместить как можно больше колонок шириной в 180 пикс. Если остаётся лишнее место, все колонки расширятся, разделив его между собой поровну. Именно это и диктует значение 1fr: сделать размеры колонок равными долями (fractions) доступной ширины.

Если растянуть окно браузера, все колонки будут одинаково расти с увеличением появившегося свободного пространства. Как только новообретённое пространство достигнет 180 пикс., на его месте появляется новая колонка. А если уменьшить окно браузера, всё происходит наоборот, идеально подгоняя сетку вплоть до превращения в одноколоночный макет. Магия!

> Видеодемонстрация

И вся эта адаптивность благодаря одной строчке кода. Ну круто же?

Создание спанов с помощью “autoflow: dense”


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

Для создания многоколоночных спанов мы можем воспользоваться свойством grid-column: span в тех элементах, которые должны занимать больше места. Допустим, мы хотим, чтобы третий элемент списка был шириной в две колонки:

.article:nth-child(3) {
  grid-column: span 2;
}

Однако, после добавления спанов может появиться немало проблем. Во-первых, в сетке могут образоваться «дырки» в тех случаях, когда широкий элемент не помещается на свою строку и auto-fit переносит его на следующую:


Это легко исправить, добавив свойство grid-auto-flow: dense к сетке. Благодаря этому свойству браузер понимает, что дырки надо заполнить другими элементами. Таким образом создаётся обтекание более широких элементов более узкими:


Обратите внимание: порядок элементов нарушен, теперь четвертый элемент встал перед третьим. Насколько я знаю, обойти это никак нельзя, это одно из ограничений CSS Grid, которые нужно принять.

Способы определить спаны


Есть несколько способов указать количество колонок, которые элемент должен занять. Проще всего применить grid-columns: span [n] к одному из элементов, где n — количество колонок, которые элемент будет занимать. Третьему элементу в нашем макете прописано свойство grid-column: span 2, что объясняет, почему его ширина в два раза больше других элементов.

Для использования других методов необходимо точно указывать линии сетки (grid lines). Линии сетки нумеруются следующим образом:


Линии сетки можно указывать слева на право с помощью положительных чисел (например, 1, 2, 3), или справа на лево с помощью отрицательных чисел (-1, -2, -3). Их можно использоваться для размещения элементов в сетке с помощью свойства grid-column, вот так:

.grid-item {
  grid-column: (начальная линия) / (конечная линия);
}

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

  • grid-column: 3 / 6
  • grid-column: -4 / -1
  • grid-column: 3 / span 3
  • grid-column: -4 / span 3
  • grid-column: span 3 / -1
  • и т.д.

В неадаптивной сетке (т.е. с фиксированным числом колонок), каждое из этих свойств даёт один и тот же результат (как в примере с синим блоком выше). Но если сетка адаптивная и количество колонок меняется, разница становится весьма заметной. Некоторые спаны ломают макет с включённым автоматически обтеканием, из-за чего кажется, что эти два решения несовместимы. К счастью, некоторые хитрости позволят нам благополучно их совместить.

Но для начала нам нужно разобраться в проблеме.

Проблемы с горизонтальной прокруткой


Вот несколько «избранных элементов», созданных с использованием метода линий сетки (кликабельно):


На всей ширине (пять колонок) всё выглядит хорошо, но если уменьшить экран до размера, при котором как бы должно быть две колонки, макет ломается таким образом:


Как видите, наша сетка утратила свою адаптивность и, хоть контейнер и сжался, сетка пытается поддерживать все пять колонок. Для этого она продолжает пытаться одинаковую ширину колонок и в итоге выходит за пределы своего контейнера справа. От этого и появляется горизонтальная прокрутка.

Почему так происходит? Проблема возникает из-за того, что браузер пытается соответствовать нашим точным указаниям линий сетки. На этой ширине, auto-fit сетка должна отображать только две колонки, но наша система нумерации линий стеки противоречит этому, обращаясь конкретно к пятой линии. Это противоречие и приводит к беспорядку. Чтобы правильно отобразить нашу подразумеваемую двухколоночную сетку, можно использовать только номера 1, 2, 3 и -3, -2, -1, вот так:


Но если один из элементов нашей сетки содержит указания grid-column вне этих пределов, скажем 4, 5 или -6, браузер получает неоднозначные указания. С одной стороны, мы просим автоматически создать гибкие колонки (которых должно — неявно — остаться две на этой ширине экрана). С другой стороны, мы явно сослались на линии сетки, которых не может существовать в двухколоночном формате. Когда появляется противоречие между неявными (автоматическими) колонками и явно определённым их количеством, сетка всегда отдаёт предпочтение явному определению. Так появляются нежелательные колонки и горизонтальный оверфлоу (что говоряще назвали CSS data loss). Спаны как и номера линий сетки могут создавать явное определение колонок. grid-column: span 3 (восьмой элемент сетки в демо) заставляет сетку явно иметь как минимум три колонки, несмотря на то, что мы хотим две неявных.

Может показаться, что единственный вариант — использовать медиа-запросы, чтобы изменять значения grid-column на нужной ширине… но не спешите! Я тоже так думал поначалу. Но, немного поразмыслив и поигравшись с разными настройками, я обнаружил, что есть некоторые пути обхода этой проблемы, благодаря которым у нас всё ещё только один медиа-запрос для самых узких устройств.

Решения


Как выяснилось, хитрость в том, чтобы определять спаны используя только номера линий, которые доступны для самой узкой сетки из планируемых к отображению. В данном случае, речь идёт о двухколоночной сетке (напоминаю, для одноколоночного отображения мы используем медиа-запрос). Таким образом, можно безопасно пользоваться номерами 1, 2, 3 и их отрицательными парами не ломая сетку.

Сначала я подумал, что так я ограничусь шириной спана в две колонки, используя эти комбинации чисел:

  • grid column: span 2
  • grid-column: 1 /3
  • grid-column: -3 / -1


Которые остаются идеально адаптивными вплоть до двух колонок:


Хоть это и работает, с точки зрения дизайна это серьёзное ограничение, и не слишком радужное. Я хотел делать спаны шириной в три, четыре или даже пять колонок на широких экранах. Но как? Моей первой мыслью снова было вернуться к медиа-запросам (омг, от старых привычек трудно избавиться), но всё же я попытался избежать этого подхода и взглянуть на адаптивный дизайн под другим углом.

Снова взглянув на список доступных чисел, я внезапно понял, что положительные и отрицательные номера в начальных и конечных значениях grid-column можно комбинировать, например 1/-3 и 2/-2. Казалось бы, ничего интересного. Но так уже не кажется, когда понимаешь положение линий после изменения размера сетки: спаны меняют ширину в соответствии с шириной экрана. Открывается целая куча новых возможностей для адаптивных спанов, в частности, элементы, которые охватывают разное количество колонок с изменением ширины экрана, без всяких медиа-запросов.

Первый обнаруженный мною пример — grid-column: 1/-1. Это свойство превращает элемент в баннер во всю ширину, заполняющий все колонки с первой по последнюю, даже когда колонка всего одна!

Используя grid-column: 1/-2, можно создать спан «почти во всю ширину», который заполняет все колонки слева направо, кроме последней. В двухколоночном макете такой спан адаптивно превращается в обычный элемент в одну колонку. Что удивительно, работает даже при сжатии макета до одной колонки. (Кажется, причина в том, что сетка не станет уменьшать элемент до нулевой ширины и поэтому он остаётся шириной в одну колонку, как в случае с grid-column: 1/1.) Я предположил, что grid-column: 2/-1 должен работать так же, только оставлять одну колонку нетронутой слева, а не справа. Оказался почти прав, при сжатии макета до одной колонки оверфлоу всё же возникает.

Затем я попробовал комбинацию 1/-3. Сработало хорошо и на широких экранах — заполняя как минимум три колонки, — и на узких — заполняя только одну. Я думал, что с двухколоночной сеткой получится нечто странное, поскольку поскольку первая линия сетки та же, что и линия под номером -3. К моему удивлению, элемент отображается верно, в одну колонку.

После многочисленных экспериментов я выяснил, что есть 11 подходящих значений grid-column из доступных в двухколоночной сетке. Три из них работают даже в одноколоночном макете. Семь других правильно работают вплоть до двух колонок и для правильного отображения в одной колонке им понадобится всего один медиа-запрос.

Вот полный список:


Демонстрация адаптивных значений grid-column на разных размерах экранах в сетке auto-fit. (Демо)

В общем, несмотря на довольно ограниченное подмножество адаптивных спанов, возможностей немало.

  • 2/-2 — интересная комбинация, создаёт центрированный спан, который работает вплоть до одноколоночной сетки!
  • 3/-1 — наименее полезная, поскольку приводит к оверфлоу даже на двух колонках.
  • 3/-3 — приятная неожиданность.

Благодаря разнообразию значений grid-column из этого списка, возможно создать интересный и полностью адаптивный макет. Используя один-единственный медиа-запрос для самого узкого одноколоночного отображения, мы можем распоряжаться десятью разными паттернами grid-column.

Тот самый медиа-запрос довольно прост, даже скажем, прямолинеен. В нашем примере он отвечает за переключение отображения сетки на flexbox:

@media (max-width: 680px) {
  .archive {
    display: flex;
    flex-direction: column;
  }

  .article {
    margin-bottom: 2em;
  }
}

Вот итоговая сетка, которая, как вы могли заметить, полностью адаптивна — от одной до пяти колонок (кликабельно):


Использование :nth-child() для повторяющейся динамической ширины


Чтобы сократить мой код до двух дюжин строк, я применил ещё одну хитрость. Селектор :nth-child(n) позволил мне стилизовать большое количество элементов сразу. Вся моя задумка со спанами должна была применяться ко многим постам в фиде, чтобы избранные блоки появлялись на странице регулярно. Сначала я писал через запятую список селекторов с чётко заданным номером элемента:

.article:nth-child(2),
.article:nth-child(18),
.article:nth-child(34),
.article:nth-child(50)  {
  background-color: rgba(128,0,64,0.8);
  grid-column: -3 / -1;
}

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

Тогда-то я и сообразил, что можно воспользоваться крутой возможностью псевдо-селектора :nth-child: вместо целочисленного значения вписывать выражение, например :nth-child(2n+ 2), которое означает каждый второй дочерний элемент.

Вот так я использовал :nth-child([формула]) для создания синего блока во всю ширину в моей сетке, который появляется в начале страницы, а затем на половине списка:

.article:nth-child(31n + 1) {
  grid-column: 1 / -1;
  background: rgba(11, 111, 222, 0.5);
}

Кусочек кода в скобках (31n + 1) отвечает за выбор 1-го, 32-го, 63-го, и т.д. дочернего элемента. Браузер запускает цикл начиная с n=0 (31 * 0 + 1 = 1), затем n=1 (31 * 1 + 1 = 32), и наконец n=2 (31 * 2 + 1 = 63). В последнем случае, браузер понимает, что 63-го дочернего элемента нет, игнорирует правило, останавливает цикл и применяет правило к 1-му и 32-му элементу.

Нечто подобное я делаю и для фиолетовых блоков, которые на протяжении страницы появляются то слева, то справа:

.article:nth-child(16n + 2) {
  grid-column: -3 / -1;
  background: rgba(128, 0, 64, 0.8);
}

.article:nth-child(16n + 10) {
  grid-column: 1 / -2;
  background: rgba(128, 0, 64, 0.8);
}

Первый селектор — для левых фиолетовых блоков. Выражение 16n + 2 отвечает за применение стилей к каждому 16-му элементу сетки, начиная со второго.

Второй селектор — для правых фиолетовых блоков. Интервал тот же(16n), но сдвиг другой (10). В результате, эти блоки регулярно встречаются с правой стороны сетки, в элементах под номерами 10, 26, 42, и т.д.

Для визуальных стилей этих элементов я использовал очередную хитрость для избежания повторения кода. Для общих стилей фиолетовых блоков (для очевидного background-color, например) можно использовать один селектор:

.article:nth-child(8n + 2) {
  background: rgba(128, 0, 64, 0.8);
  /* Other shared syling */
}

Этот селектор выберет элементы 2, 10, 18, 26, 34, 42, 50, и далее. Другими словами, он выбирает и левые, и правые блоки.

Это работает, потому что 8n — это ровно половина 16n, а разница сдвигов в двух селекторах тоже равна 8.

Заключительное слово


CSS Grid можно использовать уже сейчас для создания гибких, адаптивных сеток с минимальным количеством кода. Однако, если избегать использования ретроградных медиа-запросов, появляются и значительные ограничения позиционирования элементов в сетке.

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

.article {
  grid-column: span 3, autofit;
}

Ещё одна проблема с адаптивными сетками — последняя строка. Изменение ширины экрана может часто приводить к тому, что она оказывается незаполнена. Я долго пытался найти способ растянуть последний элемент сетки на оставшиеся колонки (соответственно, заполнить строку), но, похоже, это невозможно. По крайней мере, пока. Было бы неплохо получить возможность задавать начальную позицию элемента ключевым словом вроде auto, как бы говоря “Заполни строку до конца, начиная с левого края”. Как-то так:

.article {
  grid-column: auto, -1;
}

…что растянуло бы спан на левом краю сетки до конца строки.