I want to get into zigzag CSS layouts, because the usual advice is a mess of column-wrapping flexbox hacks that wreck accessibility and DOM order. Most developers force a fixed height on a flex container so items wrap into a second column, but that approach falls apart the moment your content length changes.
After 14 years of wrestling with front-end bottlenecks, the most stable way I have found to get that waterfall stagger is to combine CSS Grid with a calculated transform. It stays clean and keeps the natural source order, so it does not fall apart when someone tabs through the page. It also sidesteps the “two-bucket” problem, where items fill column one top to bottom before jumping to the top of column two.
The naive approach (and why it fails)
The usual recommendation is flex-direction: column with a fixed height, which forces the layout engine to wrap items into a new column once they hit that height. For dynamic content it is a disaster. If one item grows, the whole wrap point shifts and your “zigzag” collapses into a broken stack. It also wrecks the focus order: a keyboard user jumps from item 1 to item 4 visually, which is a real accessibility problem.
Building robust zigzag CSS layouts
The fix is a standard two-column grid, with every even item shifted down by half its own height. The items still sit side by side in the DOM, but the layout reads as a stagger. For background on why we are dropping flex-wraps, see my guide on modern CSS features that replace old hacks.
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
max-width: 800px;
margin: 0 auto;
}
.item {
height: 150px;
border: 2px solid #333;
}
/* The Stagger */
.item:nth-child(even of .item) {
transform: translateY(50%);
}
Notice the :nth-child(even of .item). It is more precise than nth-of-type because it filters by class, so your layout holds up even if you mix different HTML tags inside the grid. Per the MDN documentation on translateY, percentage values in transforms are relative to the element’s own height, not the parent’s. That is why a 50% shift gives a clean stagger no matter the container size.
Solving the gap math
If there is a vertical gap between rows, a 50% shift alone won’t cut it. The even items sit slightly “off” because they don’t account for the space between grid rows, so you have to fold the gap into the calc(). As Josh Comeau shows in his deep dive on CSS Transforms, pairing calc() with transforms is what gets you pixel-perfect alignment.
.wrapper {
--grid-gap: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--grid-gap);
}
.item:nth-child(even of .item) {
/* Half the height + half the gap */
transform: translateY(calc(50% + var(--grid-gap) / 2));
}
The overflow surprise
This is where it usually gets messy. I once shipped a “perfect” zigzag layout for a client and then watched the last item spill out of the footer on mobile. Transforms are applied post-layout: the browser works out the container’s height before the transform runs. So the layout engine has no clue that your sixth item just dropped 80 pixels south.
The fix is to reserve that space yourself. Give the wrapper a padding-bottom equal to the translation distance and the container will enclose its children again. That is the pragmatic workaround for the fact that transforms don’t affect document flow.
.wrapper {
--item-height: 150px;
--grid-gap: 20px;
padding-bottom: calc(var(--item-height) / 2 + var(--grid-gap) / 2);
}
If this zigzag CSS layout work is eating your dev hours, I can take it off your plate. I have been wrestling with WordPress since the 4.x days.
Refactor and ship it
Building zigzag CSS layouts doesn’t have to be a hack-fest. Lean on CSS Grid for the structure and use transforms only for the visual offset, and you end up with a layout that performs well and stays accessible. Just reserve your overflow space and keep an eye on the padding-bottom math. Then go refactor that brittle flexbox code and ship it.