Наверное, каждый, кто сталкивался с frontend`ом, хотя бы раз использовал адаптивную flex-сетку на N-ном количестве колонок. В данной статье мы не станем рассматривать область применения такого подхода, его плюсы и минусы, а разберем теорию и напишем собственное решение, с брейкпоинтами и настраиваемым спейсингом!

Данная статья, в первую очередь, будет полезна новичкам, однако надеюсь, что и опытные хабровчане найдут в ней что-то интересное. Для упрощения жизни, будем использовать SCSS, продублировав CSS «под спойлер».

Кратко о подходе с колонками

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

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

В популярных библиотеках часто встречается реализация на 12 колонках, которую мы будем использовать в этой статье в качестве «настройки по умолчанию». ��днако количество колонок ограничено лишь вашей фантазией. Чем больше их количество, тем больше возможностей для детализации размеров, но стоит учитывать, что для каждого размера потребуется создать отдельный CSS-класс.

Теория

Условная схема того, что мы хотим получить на выходе, выглядит следующим образом:

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

Пока что не будем акцентировать внимание на контейнере. С ним все просто – это базовый display: flex. Ключевую роль в этой задаче играют элементы – они то и будут принимать в себя необходимые размеры. Чтобы максимально детально разобрать решение, я попытаюсь построить «дорогу из мыслей» с подробным объяснением проблем, которые могут возникнуть в процессе реализации.

Впервые столкнувшись с задачей построения подобной сетки, я подумал о применении к элементам всего трёх CSS-свойств: flex-grow, flex-shrink и flex-basis, в конечном итоге приведенных к простому flex: 1, flex: 2, flex: 3, … flex: 12.

Это действительно сработает для создания простейшей сетки, работающей исключительно в рамках одного ряда.

Однако проблема кроется в том, чтобы ограничить то самое максимальное количество колонок на ряд. Если мы попытаемся добавить второй элемент с размером flex: 6 – он не перенесется на новый ряд. Вместо этого, пропорции просто будут пересчитаны 

Решением этой проблемы является вычисление ширины элемента в процентном соотношении. Если контейнер составляет 100%, то элемент с относительным размером 3/12 должен занимать 25% ширины.

И это действительно работает:

В итоге мы имеем простой flex-контейнер, в котором размещаем элементы, а каждый элемент имеет ширину в процентном соотношении. После чего просто добавляем контейнеру gap… 

Нет, это не сработает. На этом этапе мы сталкиваемся с особенностью процесса вычисления конечного размера для элемента. Дело в том, что когда браузер переведет наши проценты в окончательные px – он не учтет свойство gap, выставленное контейнеру. Из-за чего реальная картина будет выглядеть примерно так:

Решение проблемы с отступами:

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

И тут на помощь приходит формула 

width = 100% * $size / $columns - ($columns - $size) * ($column-gap / $columns)

где 

$size – количество колонок которое должен занимать элемент

$columns – общее количество колонок

$column-gap – расстояние между колонками.

Подробный разбор формулы:

  1. 100% * $size / $columns – вычисляем базовую ширину элемента в процентах относительно контейнера.

  2. ($columns - $size) – количество колонок, которые НЕ занимает элемент. То есть, если элемент занимает меньше колонок, чем всего в сетке, то эта часть определяет количество промежутков, которые будут между элементом и соседними элементами.

  3. ($column-gap / $columns) – это ширина одного промежутка между колонками, выраженная в доле от общей ширины контейнера.

  4. ($columns — $size) * ($column-gap / $columns) – получаем общую ширину промежутков, которые «отнимаются» от ширины элемента.

  5. Объединяя обе части, мы получаем, ширину элемента равную его доле от полной ширины контейнера с учетом промежутков между колонками.

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

Реализация (пишем код)

Основная идея заключается в том, чтобы реализовать набор CSS-классов, каждый из которых будет отвечать за свой размер элемента на определенном брейкпоинте (например .xs-12, .md-6). Такой подход позволит задавать адаптивные размеры, «переключая» размер элемента на разных экранах.

Определим SCSS map, для удобной работы c брейкпоинтами:

// SCSS

$breakpoints: (
  xs: 0,
  sm: 600,
  md: 900,
  lg: 1200,
  xl: 1536,
);

Далее добавим стили контейнера:

// SCSS

.grid-container {
  --row-gap: 0px;
  --column-gap: 0px;

  box-sizing: border-box;
  width: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: var(--row-gap) var(--column-gap);
}

Как упоминалось ранее – это базовый flex-контейнер. Сразу добавим CSS-переменные --row-gap и --column-gap – они понадобятся позднее, для того, чтобы выставить спейсинг между элементами в том месте, где применяем контейнер.

Важно отметить, что CSS переменные (да, собственно, как и gap) не поддерживаются в IE11.

Реализуем SCSS mixin, который будет отвечать за генерацию размерных классов:

// SCSS

@mixin create-grid-item($columns) {
  & {
    box-sizing: border-box;
    flex-grow: 0;
    flex-basis: auto;

    @each $breakpoint, $value in $breakpoints {
      @media (min-width: #{$value}px) {
        @for $size from 1 through $columns {
          &.#{$breakpoint}-#{$size} {
            width: calc(100% * $size / $columns - ($columns - $size) * (var(--column-gap) / $columns));
          }
        }
      }
    }
  }
}

Здесь мы:

  1. Создаем @mixin, принимающий количество колонок, которое хотим «видеть» в сетке.

  2. Задаем базовые стили для элемента.

  3. Проходясь циклом по ранее созданной мапе $breakpoints генерируем классы с возможными размерами элемента (от 1 до $columns). Для каждого брейкпоинта используем свой media-запрос. В качестве имени класса используем шаблон .#{$breakpoint}-#{$size} (<ключ_брейкпоинта>-<размер>).

  4. Применяем формулу, для вычисления ширины элемента. Стоит обратить внимание, что в ней мы используем CSS-переменную, которую ранее объявили на этапе создания стилей контейнера.

  5. Обертка & {} необходима для избежания прямого размещения деклараций CSS-свойств после at-rule (подробнее об этом можно прочитать в документации).

Далее используем @mixin create-grid-item в .grid-item:

// SCSS

.grid-item {
  @include utils.create-grid-item(12);
}

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

Скрытый текст
// screen width: > 0px
.xs-1
.xs-2
.xs-3
.xs-4
.xs-5
.xs-6
.xs-7
.xs-8
.xs-9
.xs-10
.xs-11
.xs-12

// screen width: > 600px
.sm-1
.sm-2
.sm-3
.sm-4
.sm-5
.sm-6
.sm-7
.sm-8
.sm-9
.sm-10
.sm-11
.sm-12

// screen width: > 900px
.md-1
.md-2
.md-3
.md-4
.md-5
.md-6
.md-7
.md-8
.md-9
.md-10
.md-11
.md-12

// screen width: > 1200px
.lg-1
.lg-2
.lg-3
.lg-4
.lg-5
.lg-6
.lg-7
.lg-8
.lg-9
.lg-10
.lg-11
.lg-12

// screen width: > 1536px
.xl-1
.xl-2
.xl-3
.xl-4
.xl-5
.xl-6
.xl-7
.xl-8
.xl-9
.xl-10
.xl-11
.xl-12

Теперь мы можем использовать сгенерированные классы, чтобы п��строить сетку:

<div class="grid-container" style="--row-gap: 20px; --column-gap: 20px;">
  <div class="grid-item xs-6"></div>
  <div class="grid-item xs-6"></div>
  <div class="grid-item xs-6"></div>
  <div class="grid-item xs-6 md-8 lg-12"></div>
  <div class="grid-item xs-6 md-4 lg-12"></div>
</div>

Спасибо за внимание!

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

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

Код из статьи, готовый для использования в проекте – тут (GitHub).

Обещанный CSS
.grid-container {
  --row-gap: 0px;
  --column-gap: 0px;
  box-sizing: border-box;
  width: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: var(--row-gap) var(--column-gap);
}

.grid-item {
  box-sizing: border-box;
  flex-grow: 0;
  flex-basis: auto;
}

@media (min-width: 0px) {
  .grid-item.xs-1 {
    width: calc(8.3333333333% - 11 * var(--column-gap) / 12);
  }
  .grid-item.xs-2 {
    width: calc(16.6666666667% - 10 * var(--column-gap) / 12);
  }
  .grid-item.xs-3 {
    width: calc(25% - 9 * var(--column-gap) / 12);
  }
  .grid-item.xs-4 {
    width: calc(33.3333333333% - 8 * var(--column-gap) / 12);
  }
  .grid-item.xs-5 {
    width: calc(41.6666666667% - 7 * var(--column-gap) / 12);
  }
  .grid-item.xs-6 {
    width: calc(50% - 6 * var(--column-gap) / 12);
  }
  .grid-item.xs-7 {
    width: calc(58.3333333333% - 5 * var(--column-gap) / 12);
  }
  .grid-item.xs-8 {
    width: calc(66.6666666667% - 4 * var(--column-gap) / 12);
  }
  .grid-item.xs-9 {
    width: calc(75% - 3 * var(--column-gap) / 12);
  }
  .grid-item.xs-10 {
    width: calc(83.3333333333% - 2 * var(--column-gap) / 12);
  }
  .grid-item.xs-11 {
    width: calc(91.6666666667% - 1 * var(--column-gap) / 12);
  }
  .grid-item.xs-12 {
    width: calc(100% - 0 * var(--column-gap) / 12);
  }
}

@media (min-width: 600px) {
  .grid-item.sm-1 {
    width: calc(8.3333333333% - 11 * var(--column-gap) / 12);
  }
  .grid-item.sm-2 {
    width: calc(16.6666666667% - 10 * var(--column-gap) / 12);
  }
  .grid-item.sm-3 {
    width: calc(25% - 9 * var(--column-gap) / 12);
  }
  .grid-item.sm-4 {
    width: calc(33.3333333333% - 8 * var(--column-gap) / 12);
  }
  .grid-item.sm-5 {
    width: calc(41.6666666667% - 7 * var(--column-gap) / 12);
  }
  .grid-item.sm-6 {
    width: calc(50% - 6 * var(--column-gap) / 12);
  }
  .grid-item.sm-7 {
    width: calc(58.3333333333% - 5 * var(--column-gap) / 12);
  }
  .grid-item.sm-8 {
    width: calc(66.6666666667% - 4 * var(--column-gap) / 12);
  }
  .grid-item.sm-9 {
    width: calc(75% - 3 * var(--column-gap) / 12);
  }
  .grid-item.sm-10 {
    width: calc(83.3333333333% - 2 * var(--column-gap) / 12);
  }
  .grid-item.sm-11 {
    width: calc(91.6666666667% - 1 * var(--column-gap) / 12);
  }
  .grid-item.sm-12 {
    width: calc(100% - 0 * var(--column-gap) / 12);
  }
}

@media (min-width: 900px) {
  .grid-item.md-1 {
    width: calc(8.3333333333% - 11 * var(--column-gap) / 12);
  }
  .grid-item.md-2 {
    width: calc(16.6666666667% - 10 * var(--column-gap) / 12);
  }
  .grid-item.md-3 {
    width: calc(25% - 9 * var(--column-gap) / 12);
  }
  .grid-item.md-4 {
    width: calc(33.3333333333% - 8 * var(--column-gap) / 12);
  }
  .grid-item.md-5 {
    width: calc(41.6666666667% - 7 * var(--column-gap) / 12);
  }
  .grid-item.md-6 {
    width: calc(50% - 6 * var(--column-gap) / 12);
  }
  .grid-item.md-7 {
    width: calc(58.3333333333% - 5 * var(--column-gap) / 12);
  }
  .grid-item.md-8 {
    width: calc(66.6666666667% - 4 * var(--column-gap) / 12);
  }
  .grid-item.md-9 {
    width: calc(75% - 3 * var(--column-gap) / 12);
  }
  .grid-item.md-10 {
    width: calc(83.3333333333% - 2 * var(--column-gap) / 12);
  }
  .grid-item.md-11 {
    width: calc(91.6666666667% - 1 * var(--column-gap) / 12);
  }
  .grid-item.md-12 {
    width: calc(100% - 0 * var(--column-gap) / 12);
  }
}

@media (min-width: 1200px) {
  .grid-item.lg-1 {
    width: calc(8.3333333333% - 11 * var(--column-gap) / 12);
  }
  .grid-item.lg-2 {
    width: calc(16.6666666667% - 10 * var(--column-gap) / 12);
  }
  .grid-item.lg-3 {
    width: calc(25% - 9 * var(--column-gap) / 12);
  }
  .grid-item.lg-4 {
    width: calc(33.3333333333% - 8 * var(--column-gap) / 12);
  }
  .grid-item.lg-5 {
    width: calc(41.6666666667% - 7 * var(--column-gap) / 12);
  }
  .grid-item.lg-6 {
    width: calc(50% - 6 * var(--column-gap) / 12);
  }
  .grid-item.lg-7 {
    width: calc(58.3333333333% - 5 * var(--column-gap) / 12);
  }
  .grid-item.lg-8 {
    width: calc(66.6666666667% - 4 * var(--column-gap) / 12);
  }
  .grid-item.lg-9 {
    width: calc(75% - 3 * var(--column-gap) / 12);
  }
  .grid-item.lg-10 {
    width: calc(83.3333333333% - 2 * var(--column-gap) / 12);
  }
  .grid-item.lg-11 {
    width: calc(91.6666666667% - 1 * var(--column-gap) / 12);
  }
  .grid-item.lg-12 {
    width: calc(100% - 0 * var(--column-gap) / 12);
  }
}

@media (min-width: 1536px) {
  .grid-item.xl-1 {
    width: calc(8.3333333333% - 11 * var(--column-gap) / 12);
  }
  .grid-item.xl-2 {
    width: calc(16.6666666667% - 10 * var(--column-gap) / 12);
  }
  .grid-item.xl-3 {
    width: calc(25% - 9 * var(--column-gap) / 12);
  }
  .grid-item.xl-4 {
    width: calc(33.3333333333% - 8 * var(--column-gap) / 12);
  }
  .grid-item.xl-5 {
    width: calc(41.6666666667% - 7 * var(--column-gap) / 12);
  }
  .grid-item.xl-6 {
    width: calc(50% - 6 * var(--column-gap) / 12);
  }
  .grid-item.xl-7 {
    width: calc(58.3333333333% - 5 * var(--column-gap) / 12);
  }
  .grid-item.xl-8 {
    width: calc(66.6666666667% - 4 * var(--column-gap) / 12);
  }
  .grid-item.xl-9 {
    width: calc(75% - 3 * var(--column-gap) / 12);
  }
  .grid-item.xl-10 {
    width: calc(83.3333333333% - 2 * var(--column-gap) / 12);
  }
  .grid-item.xl-11 {
    width: calc(91.6666666667% - 1 * var(--column-gap) / 12);
  }
  .grid-item.xl-12 {
    width: calc(100% - 0 * var(--column-gap) / 12);
  }
}