I am trying to achieve the popular pinterest masonry grid view in my react project. I am aware there are countless resources online that would help me achieve this but I can't quite find one for my use case.
My case:
I have a react component that can come in three pre-fixed sizes: small, medium and large. Each variation of this component has the same width of 200px on a desktop or ${windowSize.current[0]*0.45}px on mobile.
The difference in each component is in the height where a small card will have a pre-fixed height of 160px a medium 250px and a large 320px.
Here's some snippet of what the react of the dynamic card looks like:
<div className='LiveRoomCard' style={{
border: props.liveroom.joined ? '1.5px solid #d500f9' : props.liveroom.pinned ? '1.5px solid black' : '1px solid lightgray',
marginLeft: isMobile ? `${windowSize.current[0]*0.008}vw` : '2.5vw',
width: isMobile ? `${windowSize.current[0]*0.45}px` : '200px',
maxWidth: isMobile ? `${windowSize.current[0]*0.45}px` : '200px',
height: props.size === 'small' ? '160px' : props.size === 'medium' ? '250px' : '320px',
maxHeight: props.size === 'small' ? '160px' : props.size === 'medium' ? '250px' : '320px'
}}>
<div className='LiveRoomCard-image' style={{
minHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px',
maxHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px'
}}>
<img src={props.liveroom.backgroundImage ?? cardPlaceHolder} alt='post bg image' style={{
minHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px',
maxHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px'
}}/>
</div>
//rest of body that is unimportant for this question
</div>
This is what the css looks like:
.LiveRoomCard {
display: flex;
flex-direction: column;
background-color: white;
height: 320px;
max-height: 320px;
border-radius: 15px;
box-shadow: 2px 2px 2px grey;
margin-top: 4%;
margin-left: 2.5vw; /*overriden in react component*/
}
.LiveRoomCard-image {
flex: 1; /* Allow the content to take remaining top space. The LiveRoomCard-details div then takes the little at the bottom */
}
.LiveRoomCard-image img{
min-width: 100%;
max-width: 100%;
object-fit: cover; /* This maintains aspect ratio and covers the container */
border-radius: 15px 15px 0 0;
}
Now this grid of cards sits in a parent component. The parent component does a random assigning of what size each card would be:
const postCardSizes = ["small", "medium", "large"];
<div className='Liverooms-container' style={{height: isMobile ? '500px' : '700px'}}>
{rooms.map(liveroom => {
const sizeIndex = Math.floor(Math.random() * postCardSizes.length);
const randomSize = postCardSizes[sizeIndex];
return (
<div className='Liverooms-card-container' key={liveroom.roomId}>
<LiveRoomCard key={liveroom.roomId} size={randomSize}/>
</div>)
})}
</div>
And here's what the css looks like:
.Liverooms-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: scroll;
}
.Liverooms-card-container {
height: fit-content;
}
I feel like I am almost there as this is what my output looks like on desktop:
And this is what it looks like on mobile:
It goes without saying that what I am trying to achieve is the elimination of the unnecessary spacing between the rows and achieve a true masonry grid given of components of three pre-fixed heights. Is this something that is achievable or do I have to change my approach completely?
CSS "Masonry" doesn't really exist in the sense we understand/read the Masonry layout, from a UX POV (or, in less technical terms, as readers of the layout).
Yes, technically, it could be achieved using flex
and changing flex-direction
to column
or with CSS columns
property, but we'd need a script to calculate each item's position before-hand and then set the container's height
precisely so the items wrap as intended.
Also, with any of these approaches, the order of the items in DOM would be different than what is perceived as "natural flow" (we'd have all items in column A before all items in column B), and so on...
This might not seem like much of a problem, but it quickly becomes one when we resize the container and need to add or remove columns (it's basically impossible to follow where each item goes when this happens and, if the layout also requires scrolling, the user will not be able to follow how items move, making it difficult for them to continue "reading", as items from the bottom will jump at the top and vice-versa). One way to deal with this is to reorder the items on the fly when the number of columns changes. An undue complexity.
A very similar set of problems arises when adding more items to the layout, with any of the above "solutions", if we want to keep the "existing items" in place and add new ones at the bottom of each column.
In short, trying to do Masonry with flex
or CSS columns
has all the premises to turn into a coding and UX nightmare.
Besides, that's not at all how the initial Pinterest algorithm worked. On the contrary, the code was surprisingly small, elegant and, I'd argue, easy to follow.
Here are the logical steps:
set the container's position: relative
and all the masonry items to position: absolute
and display: none
(or opacity: 0
). All these, along with the item's width, can be done via CSS, from the start, and then get overridden by inline computed styles or by assigning a revealing class (or removing a hiding one).
calculate the number of available columns based on the available container width
.
Typically we'd create an empty array for each column, for storing each item's position as we calculate them, although technically we could also use a 2d matrix. Each column should keep a state of its current height, starting from 0
.
Important note: the "columns" are virtual, they don't have a corresponding DOM element. The masonry items should be immediate children of the masonry container. An item's column
determines its translateX
transform
value (see next step).
loop through the masonry items and place them in the shortest left-most column (or right-most, if you prefer). By placing we mean:
a) calculate the current item's transform: translate(x, y)
based on the column's height
and index
;
b) update column's height
by adding the current item's height
and the optional item gap(s) to it; 1
c) update the masonry DOM container's height to equal to the tallest column's height (+ appropriate gap(s)).
Having a deterministic and consistent way of placing the next item (vs placing them randomly) might not seem important, but it is, from a UX perspective. We want our users to be able to follow and be able to continue "reading" the content if they refresh or resize the page (which might change the number of columns). It is important to allow them to determine where they need to continue reading from, without undue cognitive effort. Some implementations also keep the current scroll position in state/cache to allow for more intuitive refresh behavior
The above loop is a lot faster (performant) than one might think, so typically we'd calculate all items and then start actually displaying them. I've seen versions of this script where changing display
/opacity
of the masonry item was handled inside the loop but, in my estimation, if we want any type of transition (or staggering animations) in displaying the items it is better to handle them after we've calculated all transform
s.
The outer container should have overflow: hidden
, so we don't get weird behavior on window/container resize event. Speaking of which, on window resize, eventually with some throttling, we have to re-run the above steps (without hiding the items). Note placing a short transition
on the transform
property works really well here, as the user can easily visualise where the items go when the number of columns changes. Also, the transitions make sense and are not huge jumps. They are what you'd expect a human to do when they rearrange the items from n
columns into n - 1
or n + 1
columns.
Might sound like a lot to do and think about, but it has a certain elegance and simplicity to it. It becomes more obvious once you're done coding it.
Optionally, we could turn the opacity of items off as they go out the screen, so it can be animated back on as the user scrolls up and down.
That's all there is to it.
1 - this step can get tricky when you deal with items of dynamic height, which could only be determined after rendering the item. The solution is to render the item in a hidden container of the same width as the column, detached from DOM, and measure it. Every single item has to be measured before it is placed in the grid. To avoid delays in rendering items caused by a hanging previous item still waiting for its image to load is to use placeholders. However, this means after the hanging item does render, all items after it must be repositioned (but this is fast; it's the measuring that's the most resource intensive).