Why Not Flexbox?

Most developers first reach for flex-direction: column with flex-wrap: wrap when asked to create a waterfall-like zigzag layout. It seems natural — items flow down, then wrap to the next column.

But this approach has two critical drawbacks:

  • You need a fixed container height. The flex container must know exactly how tall it is for wrapping to trigger. Change the content, and the layout breaks.
  • Tab order gets corrupted. Items flow down the first column (1, 2, 3), then jump to the second (4, 5, 6). Visually, your user sees a zigzag, but keyboard focus follows the DOM — a completely different path.

CSS Grid solves both problems elegantly. The trade-off? You’ll need to hardcode one value: the item height. But the rest of the layout stays flexible. Let’s build it.

Two-column zigzag CSS grid layout with staggered items on a laptop screen Algorithm Concept Visual

The Grid Foundation

Start with a two-column grid. No tricks yet.

*, *::before, *::after {
  box-sizing: border-box;
}

.wrapper {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  max-width: 800px;
  margin: 0 auto;
}

.item {
  height: 100px;
  border: 2px solid;
}

Why box-sizing: border-box? Without it, a 100px tall item with a 2px border is actually 104px tall. That 4px difference will throw off our transform math later.

The Stagger: translateY(50%)

Here’s the core trick — select every even item and shift it down by half its own height:

/* Select even items by class, not tag name */
.item:nth-child(even of .item) {
  transform: translateY(50%);
}

Why :nth-child(even of .item) instead of .item:nth-of-type(even)? The latter selects by tag name. Mix a <div> and a <span> in the same container, and nth-of-type will count them separately. The of .item syntax filters by class explicitly — more precise, and well-supported in modern browsers.

The Magic of Transform Percentages

Here’s what makes this work: in CSS transforms, percentages refer to the element itself, not its parent.

  • width: 50% → 50% of the parent’s width.
  • translateY(50%) → 50% of the element’s own height.

This is why the stagger stays proportional. If your items are 200px tall, they shift by 100px. If they’re 50px, they shift by 25px. The layout adapts automatically.

The Gap Fix

Set your gap to a large value (say 100px), and you’ll see the problem: even items don’t shift enough. They need to account for the vertical space between rows.

Store the gap as a custom property and add it to the translation:

.wrapper {
  --gap: 16px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap);
  max-width: 800px;
  margin: 0 auto;
}

.item:nth-child(even of .item) {
  transform: translateY(calc(50% + var(--gap) / 2));
}

We divide the gap by 2 because we only need half the distance between rows. Full gap would overshoot.

The Overflow Problem

Add a red border to the wrapper and throw in a sixth item. It spills out. Why?

Transforms don’t affect layout. The browser lays everything out first, then shifts pixels visually. The wrapper sizes itself based on the original positions, not the transformed ones.

Fix: Reserve Space with Padding

Store the item height as a custom property and add matching padding to the wrapper:

.wrapper {
  --gap: 16px;
  --item-height: 100px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--gap);
  margin: 0 auto;
  max-width: 800px;
  padding-bottom: calc(var(--item-height) / 2 + var(--gap) / 2);
}

.item {
  border: 2px solid;
  height: var(--item-height);
}

Yes, --item-height: 100px is hardcoded — the same brittleness we criticized in the flexbox approach. But here you’re locking down the item height, not the container height. The column structure, gap, and source order remain flexible. It’s a trade-off, not a deal-breaker.

Developer coding a zigzag grid layout with CSS transforms in a code editor Coding Session Visual

Accessibility Considerations

  • Screen readers are unaffected. Transforms are purely visual. The DOM order stays 1-2-3-4-5-6, and that’s exactly how assistive tech announces them.
  • Focus order stays intact. Keyboard tabbing follows source order. In our zigzag, visual and source order both cascade left-right, top-down, so they naturally agree.
  • Respect motion preferences. If you animate the stagger on page load, wrap it in a prefers-reduced-motion check:
@media (prefers-reduced-motion: no-preference) {
  .item {
    animation: slide-in 0.3s ease-out both;
  }
}

Limitations & Caveats

  • Hardcoded item height. If your items have variable content, this approach won’t work without JavaScript to measure each item.
  • No dynamic resizing. If items change height after load (e.g., expanding accordions), the stagger breaks.
  • Browser support: :nth-child(even of .item) is supported in all modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+). For older browsers, fall back to a simpler layout.

Next Steps

  • Explore subgrid for aligning nested zigzag items.
  • Try container queries to adjust item height based on container width.
  • Combine with CSS aspect-ratio for responsive items that maintain proportions.

Conclusion

The zigzag layout is three ideas stacked together:

  1. Two-column grid as the foundation.
  2. translateY(50%) creates the stagger because transform percentages reference the element itself.
  3. padding-bottom reserves space for the translated items — transforms move pixels without telling the layout engine.

Change the gap. Change the item height. Add more items. The zigzag holds.


Reference: CSS-Tricks original article

Related Reads

Minimal desk setup with code editor showing CSS grid gap and transform calculation System Abstract Visual

This content was drafted using AI tools based on reliable sources, and has been reviewed by our editorial team before publication. It is not intended to replace professional advice.