css columns ruined my portfolio grid
CSS columns seemed like an easy win for the masonry layout on my portfolio. Three lines of CSS, instant grid. Except the fill order was wrong.
The browser fills columns vertically. First project goes top of column one. Second project goes directly below it. Only after the first column is full does the browser move to column two.
That's not how people read. We read left to right, then top to bottom. The grid looked scattered.
the problems
| Issue | What happened |
|---|---|
| Fill order | Projects appeared in wrong visual sequence |
| Unbalanced heights | One column often much taller than others |
| Performance | Infinite scroll re-rendered entire grid, not just new items |
the fix
I built a manual masonry system. Each column tracks its own height. New cards go into whichever column is shortest.
const [column1, setColumn1] = useState<Project[]>([]);
const [column2, setColumn2] = useState<Project[]>([]);
const [column3, setColumn3] = useState<Project[]>([]);
const columnHeights = useRef([0, 0, 0]);height estimation
Cards have images and text. I estimate heights to distribute 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;
};distribution logic
Greedy algorithm: always pick the shortest column.
projects.forEach((project) => {
const minHeight = Math.min(...heights);
const minIndex = heights.indexOf(minHeight);
columns[minIndex].push(project);
heights[minIndex] += getCardHeight(project);
});Time complexity is O(n × m) where n is projects and m is columns. With three columns max, effectively linear.
responsive breakpoints
The grid collapses on smaller screens:
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);
}, []);tradeoffs
More setup than three lines of CSS. Height estimation, state management, resize handling. But:
- Reading order makes sense now
- Columns stay balanced
- Infinite scroll only updates new items
- Behavior is predictable and debuggable
Sometimes built-in CSS features don't fit. Building it manually gave me control, performance, and a grid that actually reads correctly.