Solving the Masonry Problem
CSS columns are a convenient way to create masonry-style layouts, and for many simple use cases, they work fine. However, when I used them to build the project grid on my portfolio, I noticed a pattern that didn’t feel quite right. The layout filled from top to bottom before moving left to right. This meant the first project appeared at the top of the first column, the second project appeared directly below it, and only after the first column was filled did the layout move to the next one.
That behavior created an unnatural reading order and made it hard to control how the grid behaved at different screen sizes. The responsive results were unpredictable, and infinite scroll caused the entire grid to re-render every time new content appeared. I wanted more control—something predictable, balanced, and smooth—so I decided to build my own system.
The problem I ran into
While experimenting with CSS columns, I ran into three main issues. The first was the fill order. Because the browser fills each column vertically, the visual flow of projects looked scattered and inconsistent. The second issue was unbalanced heights. Since the columns were filled in sequence, one column often ended up much taller than the others, which made the grid look uneven. The third issue was performance. Every time I added more projects through infinite scroll, the entire layout re-rendered instead of just the parts that changed.
These limitations made me realize that CSS columns, while easy to use, weren’t the right tool for this kind of dynamic and interactive layout.
How I approached the solution
I decided to build a manual masonry system using React state and simple logic for distributing cards. My goal was to make the layout flexible but predictable. Each column would manage its own list of projects, and the system would place new cards into whichever column currently had the least total height. This would keep the layout balanced without needing complex math or heavy calculations.
Managing columns separately
To start, I treated each column as its own independent state. This allowed React to update only the affected column when new projects were added, which prevented unnecessary re-renders.
const [column1, setColumn1] = useState<Project[]>([]);
const [column2, setColumn2] = useState<Project[]>([]);
const [column3, setColumn3] = useState<Project[]>([]);
const columnHeights = useRef([0, 0, 0]);With this setup, I could easily access and update each column while tracking their cumulative heights.
Calculating card height
Next, I needed a way to measure how tall each card was expected to be. Since the grid mixed images and text, estimating heights helped the system distribute cards more evenly.
const getCardHeight = (project: Project): number => {
const imageHeight = project.imageHeight || 400;
const headerHeight = 24;
const padding = 40;
const gap = 24;
return imageHeight + headerHeight + padding + gap;
};This simple function let the algorithm understand how much “space” each card would take before it was actually rendered.
Distributing projects across columns
With card heights in place, I distributed each project to the shortest column. The logic was straightforward but effective.
projects.forEach((project) => {
const minHeight = Math.min(...heights);
const minIndex = heights.indexOf(minHeight);
columns[minIndex].push(project);
heights[minIndex] += getCardHeight(project);
});Each time a new project was added, it went to the column with the lowest total height. This approach created a visually balanced grid without relying on CSS tricks or random ordering.
Handling responsive layouts
Finally, I needed the grid to adjust when the screen size changed. On smaller screens, it should collapse into fewer columns; on larger screens, it should expand. I used matchMedia to handle these breakpoints and re-render the grid accordingly.
useEffect(() => {
const mdQuery = window.matchMedia('(min-width: 768px)');
const lgQuery = window.matchMedia('(min-width: 1024px)');
const updateColumnCount = () => {
if (lgQuery.matches) setColumnCount(3);
else if (mdQuery.matches) setColumnCount(2);
else setColumnCount(1);
};
updateColumnCount();
mdQuery.addEventListener('change', updateColumnCount);
lgQuery.addEventListener('change', updateColumnCount);
}, []);
This made the layout fully responsive while maintaining balanced columns at any width.
Results and performance
The custom system gave me the control and predictability I wanted. The grid now reads naturally from left to right and top to bottom, with each column staying roughly even in height. Since each column manages its own state, adding more projects only updates what’s necessary, which keeps the interface smooth during infinite scroll.
The algorithm uses a greedy approach—it always places the next item in the shortest column. While this isn’t mathematically perfect, it’s more than accurate enough for a small portfolio. The overall time complexity is O(n × m), where n is the number of projects and m is the number of columns. Since there are only up to three columns, it effectively runs in linear time.
Tradeoffs and reflection
The tradeoff is that this approach requires more setup than simply writing a few lines of CSS. It involves calculating heights, managing multiple states, and handling redistribution on resize. But in exchange, the behavior is consistent, easy to reason about, and smooth even under dynamic content updates.
For a personal portfolio, the extra code is well worth the result. The layout feels balanced, responsive, and professional. It also provides full control over the visual flow, which is something CSS columns couldn’t offer.
Closing thoughts
Sometimes, the simplest built-in CSS features don’t fit the needs of a more dynamic layout. In this case, creating a manual masonry grid gave me better control, stronger performance, and a more polished presentation for my projects.
You can see this system in action on the homepage of my portfolio. Try resizing the browser and watch how the grid redistributes itself smoothly and evenly.