We need to talk about zigzag CSS layouts. For some reason, the standard advice has become a mess of column-wrapping flexbox hacks, and it’s a nightmare for accessibility and DOM order. Most developers try to force a height on a flex container just to get items to wrap into a second column, but that approach is brittle and fundamentally broken the moment your content length changes.
In my 14 years of wrestling with front-end bottlenecks, I’ve found that the most stable way to achieve that rhythmic, waterfall-like stagger is by combining CSS Grid with a calculated transform trick. It’s clean, it respects the natural source order, and it doesn’t break when a user tabs through the page. Furthermore, it avoids the “two-bucket” problem where items flow down column one before jumping to the top of column two.
The Naive Approach (and Why It Fails)
Typically, you’d see a recommendation to use flex-direction: column with a fixed height. Consequently, the layout engine is forced to wrap items into a new column once the height limit is hit. However, this is a disaster for dynamic content. If one item grows, the whole wrap point shifts, and suddenly your “zigzag” is just a broken stack. Specifically, it ruins the focus order; a keyboard user will jump from item 1 to item 4 visually, which is a massive accessibility fail.
Building Robust Zigzag CSS Layouts
The solution is to use a standard two-column grid and then shift every even item down by half its own height. This keeps the items sitting side-by-side in the DOM while creating the visual stagger we want. Before we dive into the code, you might want to check out my guide on modern CSS features that replace old hacks for more context on why we’re moving away from flex-wraps.
.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 use of :nth-child(even of .item). This is more precise than nth-of-type because it filters by class, ensuring your layout doesn’t break if you mix different HTML tags inside your grid. According to the MDN documentation on translateY, percentage values in transforms refer to the element’s own height, not the parent’s. This is why the 50% shift creates a perfect stagger regardless of the container size.
Solving the Gap Math
If you have a vertical gap between rows, the 50% shift alone won’t be enough. The even items will look slightly “off” because they aren’t accounting for the extra space between grid rows. Therefore, we need to incorporate the gap into our calc() logic. As Josh Comeau points out in his deep dive on CSS Transforms, using calc() with transforms is the secret sauce for 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
Here is where things usually get messy. I remember shipping a “perfect” zigzag layout for a client only to find the last item spilling out of the footer on mobile. Transforms are applied post-layout. The browser calculates the container’s height before the transform is applied. As a result, the layout engine has no idea that your sixth item just jumped 80 pixels south.
To fix this, you must reserve the space manually. By adding a padding-bottom to the wrapper that matches the translation distance, you ensure the container encloses its children. This 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);
}
Look, if this zigzag CSS layouts stuff is eating up your dev hours, let me handle it. I’ve 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. By sticking to CSS Grid for the foundation and using the transform property for the visual flair, you get a layout that is both performant and accessible. Just remember: always reserve your overflow space and keep an eye on your padding-bottom calculations. Now go refactor that brittle flexbox code and ship it.