Создание работающей сегодня плиточной разметки типа Masonry

alexei13/08/2025 - 08:33
Создание работающей сегодня плиточной разметки типа Masonry

Перевод статьи Зелла Лиу (Zell Liew) "Making a Masonry Layout That Works Today"

В прошлом году многие эксперты по CSS активно обсуждали возможные синтаксические конструкции новой функции макета Masonry (разметка кирпичиками). Было два основных лагеря мнений и третий лагерь, который пытался найти баланс между ними:

  1. Использовать display: masonry
  2. Применять grid-template-rows: masonry
  3. Установить свойство item-pack: collapse

Насколько мне известно, пока не удалось прийти к какому-то общему решению. Но вы можете заинтересоваться тем фактом, что уже сейчас Firefox поддерживает этот тип разметки через второй вариант синтаксиса, тогда как Chrome тестирует поддержку первого варианта. Хотя приятно видеть развитие нативной поддержки Masonry в CSS, мы не можем использовать её в продакшене, если другие браузеры ещё не поддерживают аналогичную реализацию...

Поэтому вместо того чтобы присоединяться к одному из лагерей, я решил выяснить, как заставить такой вид разметки работать прямо сейчас во всех остальных браузерах. Рад сообщить вам, что нашёл способ сделать это — причём всего лишь с помощью 66 строк JavaScript-кода.

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

Что вообще здесь происходит?!

Итак, несмотря на то, что в этой демонстрации всего лишь 66 строк JavaScript-кода, я включил сюда кучу интересных вещей:

  • Вы можете определить плиточную сетку (masonry) с любым количеством колонок.
  • Каждый элемент может занимать несколько колонок.
  • Мы дожидаемся загрузки медиа перед вычислением размера каждого элемента.
  • Сетка адаптивна благодаря использованию ResizeObserver для отслеживания изменений.

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

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

Итак, пока вас окончательно не захлестнуло волнение, давайте вернемся к главному вопросу: каким образом всё это работает?

Начнём с полифилла

Firefox уже поддерживает плиточные раскладки (masonry) через синтаксис второго лагеря. Вот CSS-код, который вам нужен для создания плиточной сетки CSS в Firefox.


.masonry {
  display: grid;
  grid-template-columns: repeat(
    auto-fill,
    minmax(min(var(--item-width, 200px), 100%), 1fr)
  );
  grid-template-rows: masonry;
  grid-auto-flow: dense; /* Необязательно, но рекомендуется */
}

Так как Firefox уже имеет нативную поддержку плиточной раскладки, естественно мы не будем брать ее во внимание. Лучший способ проверить, поддерживается ли плиточная сетка по умолчанию — убедиться, что свойство grid-template-rows принимает значение masonry.


function isMasonrySupported(container) {
  return getComputedStyle(container).gridTemplateRows === 'masonry'
}

Если значение masonry поддерживается, то мы пропустим нашу реализацию. В обратном случае мы будем с этим что-то делать.


const containers = document.querySelectorAll('.masonry')

containers.forEach(async container => {
  if (isMasonrySupported(container)) return
})

Плиточная вёрстка (masonry) упрощена

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

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

Техника заключается вот в чём:

  1. Мы устанавливаем значение grid-auto-rows равное 0px.
  2. Затем задаём интервал между рядами (row-gap) размером в 1px.
  3. Далее получаем высоту элемента через метод getBoundingClientRect.
  4. После этого мы определяем размер "выделенной строки" для элемента путём сложения высоты (height) и значения интервала столбцов (column-gap).

Это кажется совершенно нелогичным, если вы привыкли использовать стандартную сетку CSS. Но как только вы разберётесь, вы сможете понять принцип работы!

Поскольку это настолько интуитивно непонятно, давайте пройдём всё поэтапно, чтобы увидеть, как вся конструкция постепенно трансформируется в конечный результат.

Шаг за шагом

Во-первых, мы устанавливаем свойство grid-auto-rows равным 0px. Это звучит странно, потому что фактически каждый элемент сетки будет иметь нулевую высоту. Однако одновременно сетка CSS сохраняет порядок колонок и строк!


containers.forEach(async (container) => {
	// ...
	container.style.gridAutoRows = '0px'
})

Свойство grid-auto-rows установлено равным 0px
Свойство grid-auto-rows установлено равным 0px

Во-вторых, мы задаем значение свойства row-gap равное 1px. После этого вы начинаете замечать начальное наложение рядов один поверх другого, каждый ряд смещается ровно на один пиксель ниже предыдущего.


containers.forEach(async (container) => {
	// ...
	container.style.gridAutoRows = '0px';
	container.style.setProperty('row-gap', '1px', 'important');
})

Карточки остаются в трех колонках, но располагаются одна над другой
Карточки остаются в трех колонках, но располагаются одна над другой

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

Затем мы можем восстановить "высоту" элемента сетки в CSS Grid, заменив значение grow-row-end значением высоты. Это работает потому, что теперь каждая строка промежутка между рядами row-gap равна 1px.

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


containers.forEach(async container => {
  // ...
  let items = container.children
  layout({ items })
})

function layout({ items }) {
  items.forEach(item => {
    const ib = item.getBoundingClientRect()
    item.style.gridRowEnd = 'span ' + Math.round(ib.height)
  })
}

Макет карточек-контейнеров с чередованием одной и двух колонок
Макет карточек-контейнеров с чередованием одной и двух колонок

Теперь нам нужно вернуть промежуток между элементами сетки. К счастью, поскольку в сеточных макетах типа Masonry обычно значения промежутков между столбцами (column-gap) и строками (row-gap) совпадают, требуемое значение промежутка строки можно получить через значение column-gap.

Далее добавляем этот отступ к параметру grid-row-end, чтобы увеличить количество занимаемых рядов ("высота") элементом в сетке:


containers.forEach(async container => {
  // ...
  const items = container.children
  const colGap = parseFloat(getComputedStyle(container).columnGap)
  layout({ items, colGap })
})

function layout({ items, colGap }) {
  items.forEach(item => {
    const ib = item.getBoundingClientRect()
    item.style.gridRowEnd = `span ${Math.round(ib.height + colGap)}`
  })
}

Плиточная сетка. Порядок идет слева направо.
Плиточная сетка. Порядок идет слева направо.

И вот таким образом мы создали плиточную сетку! Всё дальнейшее — лишь подготовка макета к полноценной работе на действующем сайте.

Ожидание загрузки медиа

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

Первый элемент макета содержит изображение и располагается сзади остальных элементов
Первый элемент макета содержит изображение и располагается сзади остальных элементов

Это неправильно, поскольку мы взяли значение высоты до того, как изображение было загружено должным образом. DOM еще не знает размеры изображения. Чтобы исправить ситуацию, нужно дождаться загрузки медиафайлов перед запуском функции формирования макета.

Мы можем сделать это следующим кодом (который я здесь подробно объяснять не буду, ведь это не такой уж хитрый трюк с CSS):


containers.forEach(async container => {
    // ...
    try {
        await Promise.all([areImagesLoaded(container), areVideosLoaded(container)])
    } catch(e) {}
    
    // Запускаем функцию формирования макета после загрузки изображений
    layout({ items, colGap })
})

// Проверяет загрузились ли изображения
async function areImagesLoaded(container) {
    const images = Array.from(container.querySelectorAll('img'))
    const promises = images.map(img => {
        return new Promise((resolve, reject) => {
            if (img.complete) return resolve()
            img.onload = resolve
            img.onerror = reject
        })
    })
    return Promise.all(promises)
}

// Проверяет загрузились ли видеофайлы
function areVideosLoaded(container) {
    const videos = Array.from(container.querySelectorAll('video'))
    const promises = videos.map(video => {
        return new Promise((resolve, reject) => {
            if (video.readyState === 4) return resolve()
            video.onloadedmetadata = resolve
            video.onerror = reject
        })
    })
    return Promise.all(promises)
}

Вот и всё — теперь у нас есть работающий плиточный макет типа Masonry с изображениями и видео!

Полностью готовый плиточный макет Masonry с шестью элементами
Полностью готовый плиточный макет Masonry с шестью элементами

Делаем сетку адаптивной

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

Когда происходят какие-то изменения, мы снова запускаем функцию компоновки layout:


containers.forEach(async container => {
  // ...
  const observer = new ResizeObserver(observerFn);
  observer.observe(container);

  function observerFn(entries) {
    for (const entry of entries) {
      layout({ colGap, items });
    }
  }
});

Этот пример использует стандартный API Resize Observer. Но вы можете упростить код, используя улучшенную версию функции resizeObserver.


containers.forEach(async container => {
  // ...
  const observer = resizeObserver(container, {
    callback() {
      layout({ colGap, items });
    },
  });
});

Вот и всё! Теперь у вас есть надёжная адаптивная плиточная сетка типа Masonry, которую можно использовать во всех браузерах, поддерживающих CSS Grid!

И при этом реализация проста в применении! Здорово, правда?