A Lightweight Masonry Solution
Back in May, I learned about Firefox adding masonry to CSS grid. Masonry layouts are something I’ve been wanting to do on my own from scratch for a very long time, but have never known where to start. So, naturally, I checked the demo and then I had a lightbulb moment when I understood how this new proposed CSS feature works.
Support is obviously limited to Firefox for now (and, even there, only behind a flag), but it still offered me enough of a starting point for a JavaScript implementation that would cover browsers that currently lack support.
The way Firefox implements masonry in CSS is by setting either grid-template-rows
(as in the example) or grid-template-columns
to a value of masonry
.
My approach was to use this for supporting browsers (which, again, means just Firefox for now) and create a JavaScript fallback for the rest. Let’s look at how this works using the particular case of an image grid.
First, enable the flag
In order to do this, we go to about:config
in Firefox and search for “masonry.” This brings up the layout.css.grid-template-masonry-value.enabled
flag, which we enable by double clicking its value from false
(the default) to true
.
Let’s start with some markup
The HTML structure looks something like this:
<section class="grid--masonry">
<img src="black_cat.jpg" alt="black cat" />
<!-- more such images following -->
</section>
Now, let’s apply some styles
The first thing we do is make the top-level element a CSS grid container. Next, we define a maximum width for our images, let’s say 10em
. We also want these images to shrink to whatever space is available for the grid’s content-box
if the viewport becomes too narrow to accommodate for a single 10em
column grid, so the value we actually set is Min(10em, 100%)
. Since responsivity is important these days, we don’t bother with a fixed number of columns, but instead auto-fit
as many columns of this width as we can:
$w: Min(10em, 100%);
.grid--masonry {
display: grid;
grid-template-columns: repeat(auto-fit, $w);
> * { width: $w; }
}
Note that we’ve used Min()
and not min()
in order to avoid a Sass conflict.
Well, that’s a grid!
Not a very pretty one though, so let’s force its content to be in the middle horizontally, then add a grid-gap
and padding
that are both equal to a spacing value ($s
). We also set a background
to make it easier on the eyes.
$s: .5em;
/* masonry grid styles */
.grid--masonry {
/* same styles as before */
justify-content: center;
grid-gap: $s;
padding: $s
}
/* prettifying styles */
html { background: #555 }
Having prettified the grid a bit, we turn to doing the same for the grid items, which are the images. Let’s apply a filter
so they all look a bit more uniform, while giving a little additional flair with slightly rounded corners and a box-shadow
.
img {
border-radius: 4px;
box-shadow: 2px 2px 5px rgba(#000, .7);
filter: sepia(1);
}
The only thing we need to do now for browsers that support masonry
is to declare it:
.grid--masonry {
/* same styles as before */
grid-template-rows: masonry;
}
While this won’t work in most browsers, it produces the desired result in Firefox with the flag enabled as explained earlier.
But what about the other browsers? That’s where we need a…
JavaScript fallback
In order to be economical with the JavaScript the browser has to run, we first check if there are any .grid--masonry
elements on that page and whether the browser has understood and applied the masonry
value for grid-template-rows
. Note that this is a generic approach that assumes we may have multiple such grids on a page.
let grids = [...document.querySelectorAll('.grid--masonry')];
if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
console.log('boo, masonry not supported 😭')
}
else console.log('yay, do nothing!')
If the new masonry feature is not supported, we then get the row-gap
and the grid items for every masonry grid, then set a number of columns (which is initially 0
for each grid).
let grids = [...document.querySelectorAll('.grid--masonry')];
if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
grids = grids.map(grid => ({
_el: grid,
gap: parseFloat(getComputedStyle(grid).gridRowGap),
items: [...grid.childNodes].filter(c => c.nodeType === 1),
ncol: 0
}));
grids.forEach(grid => console.log(`grid items: ${grid.items.length}; grid gap: ${grid.gap}px`))
}
Note that we need to make sure the child nodes are element nodes (which means they have a nodeType
of 1
). Otherwise, we can end up with text nodes consisting of carriage returns in the array of items.
Before proceeding further, we have to ensure the page has loaded and the elements aren’t still moving around. Once we’ve handled that, we take each grid and read its current number of columns. If this is different from the value we already have, then we update the old value and rearrange the grid items.
if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
grids = grids.map(/* same as before */);
function layout() {
grids.forEach(grid => {
/* get the post-resize/ load number of columns */
let ncol = getComputedStyle(grid._el).gridTemplateColumns.split(' ').length;
if(grid.ncol !== ncol) {
grid.ncol = ncol;
console.log('rearrange grid items')
}
});
}
addEventListener('load', e => {
layout(); /* initial load */
addEventListener('resize', layout, false)
}, false);
}
Note that calling the layout()
function is something we need to do both on the initial load and on resize.
To rearrange the grid items, the first step is to remove the top margin on all of them (this may have been set to a non-zero value to achieve the masonry effect before the current resize).
If the viewport is narrow enough that we only have one column, we’re done!
Otherwise, we skip the first ncol
items and we loop through the rest. For each item considered, we compute the position of the bottom edge of the item above and the current position of its top edge. This allows us to compute how much we need to move it vertically such that its top edge is one grid gap below the bottom edge of the item above.
/* if the number of columns has changed */
if(grid.ncol !== ncol) {
/* update number of columns */
grid.ncol = ncol;
/* revert to initial positioning, no margin */
grid.items.forEach(c => c.style.removeProperty('margin-top'));
/* if we have more than one column */
if(grid.ncol > 1) {
grid.items.slice(ncol).forEach((c, i) => {
let prev_fin = grid.items[i].getBoundingClientRect().bottom /* bottom edge of item above */,
curr_ini = c.getBoundingClientRect().top /* top edge of current item */;
c.style.marginTop = `${prev_fin + grid.gap - curr_ini}px`
})
}
}
We now have a working, cross-browser solution!
A couple of minor improvements
A more realistic structure
In a real world scenario, we’re more likely to have each image wrapped in a link to its full size so that the big image opens in a lightbox (or we navigate to it as a fallback).
<section class='grid--masonry'>
<a href='black_cat_large.jpg'>
<img src='black_cat_small.jpg' alt='black cat'/>
</a>
<!-- and so on, more thumbnails following the first -->
</section>
This means we also need to alter the CSS a bit. While we don’t need to explicitly set a width
on the grid items anymore — as they’re now links — we do need to set align-self: start
on them because, unlike images, they stretch to cover the entire row height by default, which will throw off our algorithm.
.grid--masonry > * { align-self: start; }
img {
display: block; /* avoid weird extra space at the bottom */
width: 100%;
/* same styles as before */
}
Making the first element stretch across the grid
We can also make the first item stretch horizontally across the entire grid (which means we should probably also limit its height
and make sure the image doesn’t overflow or get distorted):
.grid--masonry > :first-child {
grid-column: 1/ -1;
max-height: 29vh;
}
img {
max-height: inherit;
object-fit: cover;
/* same styles as before */
}
We also need to exclude this stretched item by adding another filter criterion when we get the list of grid items:
grids = grids.map(grid => ({
_el: grid,
gap: parseFloat(getComputedStyle(grid).gridRowGap),
items: [...grid.childNodes].filter(c =>
c.nodeType === 1 &&
+getComputedStyle(c).gridColumnEnd !== -1
),
ncol: 0
}));
Handling grid items with variable aspect ratios
Let’s say we want to use this solution for something like a blog. We keep the exact same JS and almost the exact same masonry-specific CSS – we only change the maximum width a column may have and drop the max-height
restriction for the first item.
As it can be seen from the demo below, our solution also works perfectly in this case where we have a grid of blog posts:
You can also resize the viewport to see how it behaves in this case.
However, if we want the width of the columns to be somewhat flexible, for example, something like this:
$w: minmax(Min(20em, 100%), 1fr)
Then we have a problem on resize:
The changing width of the grid items combined with the fact that the text content is different for each means that when a certain threshold is crossed, we may get a different number of text lines for a grid item (thus changing the height
), but not for the others. And if the number of columns doesn’t change, then the vertical offsets don’t get recomputed and we end up with either overlaps or bigger gaps.
In order to fix this, we need to also recompute the offsets whenever at least one item’s height
changes for the current grid. This means we need to also need to test if more than zero items of the current grid have changed their height
. And then we need to reset this value at the end of the if
block so that we don’t rearrange the items needlessly next time around.
if(grid.ncol !== ncol || grid.mod) {
/* same as before */
grid.mod = 0
}
Alright, but how do we change this grid.mod
value? My first idea was to use a ResizeObserver:
if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
let o = new ResizeObserver(entries => {
entries.forEach(entry => {
grids.find(grid => grid._el === entry.target.parentElement).mod = 1
});
});
/* same as before */
addEventListener('load', e => {
/* same as before */
grids.forEach(grid => { grid.items.forEach(c => o.observe(c)) })
}, false)
}
This does the job of rearranging the grid items when necessary even if the number of grid columns doesn’t change. But it also makes even having that if
condition pointless!
This is because it changes grid.mod
to 1
whenever the height
or the width
of at least one item changes. The height
of an item changes due to the text reflow, caused by the width
changing. But the change in width
happens every time we resize the viewport and doesn’t necessarily trigger a change in height
.
This is why I eventually decided on storing the previous item heights and checking whether they have changed on resize to determine whether grid.mod
remains 0
or not:
function layout() {
grids.forEach(grid => {
grid.items.forEach(c => {
let new_h = c.getBoundingClientRect().height;
if(new_h !== +c.dataset.h) {
c.dataset.h = new_h;
grid.mod++
}
});
/* same as before */
})
}
That’s it! We now have a nice lightweight solution. The minified JavaScript is under 800 bytes, while the strictly masonry-related styles are under 300 bytes.
But, but, but…
What about browser support?
Well, @supports
just so happens to have better browser support than any of the newer CSS features used here, so we can put the nice stuff inside it and have a basic, non-masonry grid for non-supporting browsers. This version works all the way back to IE9.
It may not look the same, but it looks decent and it’s perfectly functional. Supporting a browser doesn’t mean replicating all the visual candy for it. It means the page works and doesn’t look broken or horrible.
What about the no JavaScript case?
Well, we can apply the fancy styles only if the root element has a js
class which we add via JavaScript! Otherwise, we get a basic grid where all the items have the same size.
The post A Lightweight Masonry Solution appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.