2023-02-25
Masonry layout with React
Masonry is a grid-like layout that arranges elements to fill empty spaces and create an aesthetically pleasing grid. At Material, we use it on our "love page" to showcase social media posts and Tweets from our users.
Let's explore how to create this layout from the ground up using a React component.
Why implement Masonry from scratch
When solving a front-end problem, we should consider whether to use a third-party library or create our own solution. Advantages of third-party libraries such as react-masonry-component and material-ui/react-masonry include being already implemented and tested in production, while their disadvantages include a less flexible interface and no control over rendering performance.
In our use case, we want to display an ordered list of social media posts. The most relevant ones should be rendered at the top of the page. Each post has the same width, but the height varies depending on the content.
To produce a more uniform look, the first step is to discretize the heights. The minimum post height step will become the height of the CSS grid row.
We want to dynamically determine the number of columns based on the browser width for improved performance. Additionally, we must ensure the website works well with Server Side Rendering to make social media posts available for SEO.
We decided to implement the requirements from scratch using CSS Grid and standard CSS properties to control the look and feel. This design choice allows anyone with knowledge of CSS to make visual changes to the design.
General Architecture
To make a component work quickly, you should use as little JavaScript as possible and let the browser's CSS engine do most of the work. This will give the best performance for the user, as browsers are designed to work well with CSS.
CSS grid
The CSS grid layout almost meets our needs, but we need to manually calculate the coordinates of each post in the grid system to preserve the vertical order. The default CSS grid positioning won't work, as it positions posts left to right without taking the vertical order into account.
CSS grid with manual element positioning
To accurately place each post on the grid, we need to know its height. Instead of trying to estimate it, we render invisible posts first to read their height directly from the DOM. This technique is better than guessing the height, as it takes into account paddings, font sizes, line heights, and any other CSS property of the post without needing a complex calculation. The downside is that it uses the first render on invisible components.
After calculating the position of each post in the grid system, we need to write the CSS properties style.gridColumnStart, style.gridRowStart, and style.gridRowEnd into the DOM nodes of each post. This provides the information necessary for CSS grid to render the posts in their correct positions, ensuring that they do not overlap.
Whenever the container's size changes, we recalculate the positions. To ensure optimal performance, we add a throttle to the resize handler, as it is called frequently and can result in excessive DOM manipulation.
Implementation
Let’s now see a simplified implementation of the component. Note that we are using standard CSS grid properties to control the layout.
These are the steps:
- Render invisible posts to get the actual height
- Calculate every post position on the grid and set the element CSS properties
- When a resize happens, re-render
The calculateLayout() function is the core of our component, it’s where we assign a position in our grid system to every social media post.
Final thoughts
The Masonry layout we just implemented can be used to display multiple similar elements neatly arranged in a grid-like system, avoiding the "everything fits into a square" look. The advantage of our CSS grid implementation is that we can use any of the standard CSS grid layout properties to style it. This allows us to dynamically adjust the gap between elements and the number of columns based on our responsive design. This isn't something we can easily do with third-party libraries, as they usually require non-standard layout properties or access to their internals, which I try to avoid when possible.
In the future, there are several improvements we can make to our component:
- Use IntersectionObserver instead of getBoundingClientRect() for better performance.
- Use ReactDOM.render() or ReactDOM.createPortal() to render the posts synchronously in a separate hidden container, instead of using the first component render.