
Перевод статьи Зелла Лиу (Zell Liew) "Making a Masonry Layout That Works Today"
В прошлом году многие эксперты по CSS активно обсуждали возможные синтаксические конструкции новой функции макета Masonry (разметка кирпичиками). Было два основных лагеря мнений и третий лагерь, который пытался найти баланс между ними:
- Использовать
display: masonry
- Применять
grid-template-rows: masonry
- Установить свойство
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) упрощена
Прежде всего стоит пояснить, что я не являюсь изобретателем данной техники.
Я нашел эту технику, когда рыскал по сети в поисках современных возможных способов реализации плиточной сетки. Так что похвалить следует неизвестного разработчика, который первым придумал этот способ, а также, возможно, и меня за понимание, преобразование и использование её.
Техника заключается вот в чём:
- Мы устанавливаем значение
grid-auto-rows
равное0px
. - Затем задаём интервал между рядами (
row-gap
) размером в1px
. - Далее получаем высоту элемента через метод
getBoundingClientRect
. - После этого мы определяем размер "выделенной строки" для элемента путём сложения высоты (
height
) и значения интервала столбцов (column-gap
).
Это кажется совершенно нелогичным, если вы привыкли использовать стандартную сетку CSS. Но как только вы разберётесь, вы сможете понять принцип работы!
Поскольку это настолько интуитивно непонятно, давайте пройдём всё поэтапно, чтобы увидеть, как вся конструкция постепенно трансформируется в конечный результат.
Шаг за шагом
Во-первых, мы устанавливаем свойство grid-auto-rows
равным 0px
. Это звучит странно, потому что фактически каждый элемент сетки будет иметь нулевую высоту. Однако одновременно сетка CSS сохраняет порядок колонок и строк!
containers.forEach(async (container) => {
// ...
container.style.gridAutoRows = '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 с изображениями и видео!

Делаем сетку адаптивной
Это простой шаг. Нам нужно всего лишь использовать 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!
И при этом реализация проста в применении! Здорово, правда?