blogprojectshajspace
Back to blog

css columns ruined my portfolio grid

·2 min read

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.

ts
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:

ts
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.

ts
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:

ts
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.

Back to blog