Building Responsive Pyramidal Grids With Modern CSS

Following up on the previous exploration of hexagon grids, we now look at a pyramidal arrangement of the same hexagonal shapes. This implementation depends on a set of recently shipped CSS features — corner-shape, sibling-index(), and unit division — so it currently runs only in Chrome. The result is a fully responsive pyramid that adapts without a single media query or line of JavaScript.

Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

As with the earlier hexagon grid, this is a modern rewrite of a 2021 approach. The core idea remains: place hexagon items so that rows shift inward as they climb, forming a pyramid. But where the old version leaned on margins and Flexbox, this version takes advantage of CSS Grid's auto-placement, which cuts down on the positioning logic considerably.

Setting Up the Grid Container

The grid setup is simpler than you might think. Instead of Flexbox, we use CSS Grid with the standard repeated auto-fit pattern to generate columns based on available space:

<div class="container">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <!-- etc. -->
</div>
.container {
  --s: 40px;  /* size  */
  --g: 5px;   /* gap */

  display: grid;
  grid-template-columns: repeat(auto-fit, var(--s) var(--s));
  justify-content: center;
  gap: var(--g);
}

.container > * {
  grid-column-end: span 2;
  aspect-ratio: cos(30deg);
  border-radius: 50% / 25%;
  corner-shape: bevel;
  margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));
}

The hexagon shapes themselves come from the same code used previously. One key detail: the item size variable var(--s) is written twice in the sizing logic — that's intentional. Each item spans two grid columns via grid-column-end: span 2, guaranteeing an even number of columns at all times. This even count is what makes the row-to-row shifting between pyramid and regular grid states possible.

Zooming into the gap between hexagon shapes, which are highlighted in pink.

Since items occupy two columns, the effective item width becomes 2*var(--s) + var(--g). That changes the negative bottom margin needed for the hexagon overlap effect compared with the previous single-column version.

So, instead of this:

margin-bottom: calc(var(--s)/(-4*cos(30deg)));

…we use this:

margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));

With that structure in place, most of the work is already done. Getting the pyramid to render correctly comes down to one property: grid-column-start. The complexity is entirely in the calculation that decides which column each item lands on.

Wiring Up the Pyramid With Grid Placement

With a container large enough to fit the entire pyramid, the layout pattern is consistent regardless of item count. The first item in each row — items 1, 2, 4, 7, 11, and so on — always anchors its row. Each of these is linked: if item 1 sits in column x, item 2 sits in column x-1, item 4 in column x-2, and so forth.

A stack of 28 hexagon shapes arranged in a pyramid-shaped grid. The first diagonal row on the right is highlighted showing how the shapes are aligned on the sides.

Item 1 logically sits in the middle, so with N columns, its position is:

:nth-child(1) { grid-column-start: N/2 - 0 }
:nth-child(2) { grid-column-start: N/2 - 1 }
:nth-child(4) { grid-column-start: N/2 - 2 }
:nth-child(7) { grid-column-start: N/2 - 3 }
:nth-child(11){ grid-column-start: N/2 - 4 }

Since each item spans two columns, N/2 also equals the maximum number of items that fit in a row. Counting items rather than columns keeps things consistent with the previous article's logic. The total item count is derived from the same formula as before, only adjusted for the new item width:

.container {
  --s: 40px;  /* size  */
  --g: 5px;   /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}

.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
}

.container > *:nth-child(1) { grid-column-start: calc(var(--_n) - 0) }
.container > *:nth-child(2) { grid-column-start: calc(var(--_n) - 1) }
.container > *:nth-child(4) { grid-column-start: calc(var(--_n) - 2) }
.container > *:nth-child(7) { grid-column-start: calc(var(--_n) - 3) }
.container > *:nth-child(11){ grid-column-start: calc(var(--_n) - 4) }
/* etc. */

At this point the pyramid renders, but only with a series of verbose :nth-child() rules targeting each row anchor.

:nth-child(1) { grid-column-start: ?? }
:nth-child(2) { grid-column-start: ?? }
:nth-child(4) { grid-column-start: ?? }
:nth-child(7) { grid-column-start: ?? }
:nth-child(11) { grid-column-start: ?? }
/* etc. */

Replacing :nth-child() With Math

That selector list is ugly. Removing it requires identifying row anchors using triangular numbers. In mathematical terms, the item index for each anchor follows a predictable quadratic pattern. Squaring that away, we end up with a simple test: for each item, check if sqrt(2*index - 1.75) - .5 is a whole number.

j = sqrt(2*index - 1.75) - .5

In CSS, the item index comes from sibling-index(). A temporary variable --_d holds the decimal remainder of the formula; when it equals 0, the index is a triangular anchor point, so the item gets its column assignment from calc(var(--_n) - var(--_j)). Because the smallest index yields --_j of 0, the value is always positive and there is no need to test for that.

.container {
  --s: 40px; /* size  */
  --g: 5px; /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
  --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
  --_d: mod(var(--_j),1);
  grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););
}

Tada! Three lines of CSS now handle any arbitrary number of items. The pyramid works — for the static case.

Adding the Responsive Transition

Making it responsive requires dealing with both layouts at once. When the container is large, the pyramid forms. When space runs out, the items below overflow into the familiar classic grid pattern from the previous article.

Showing a stack of hexagon shapes arranged in two shapes: on top is the pyramid grid and below that it becomes a rectangular grid.

Items 1 through 28 form the pyramid; from item 29 onward, we need classic grid behavior. That means targeting the first items in rows of the overflow section — 29, 42, and so on — and shifting them to column 2. As before, these items follow a formula based on their index.

N*i + (N - 1)*(i - 1) + 1 + N*(N - 1)/2 = index

Solving that expression, we check whether

i = (index - 2 + N*(3 - N)/2)/(2*N - 1)

yields a positive integer. If so, the item sets grid-column-start to 2. Negative values from this formula occur for some indices, but those don't matter because they will never satisfy the integer check.

.container {
  --s: 40px; /* size  */
  --g: 5px; /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));

  /* code for the pyramidal grid */
  --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
  --_d: mod(var(--_j),1);
  grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););

  /* code for the responsive grid */
  --_i: calc((sibling-index() - 2 + (var(--_n)*(3 - var(--_n)))/2)/(2*var(--_n) - 1));
  --_c: mod(var(--_i),1);
  grid-column-start: if(style((--_i > 0) and (--_c: 0)): 2;);
}

Declaring grid-column-start twice is not an option — only one declaration survives. Both conditions need to be folded into a single if() statement:

grid-column-start:
if(
  style((--_i > 0) and (--_c: 0)): 2; /* first condition */
  style(--_d: 0): calc(var(--_n) - var(--_j)); /* second condition */
);

The order matters. When both the pyramid and responsive conditions match the same item — item 29 is exactly that case — the responsive rule must win. That's where the pyramid gives way to the overflow grid.

Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

But testing reveals a snag. Items that are part of the pyramid structure — item 37, for instance — still pass the pyramid check even after the overlay grid begins. They keep receiving a computed column start from calc(var(--_n) - var(--_j)), and that throws off the auto-placement.

For a given N, some of those computed values inevitably go negative. Negative column values are technically valid in Grid, so those items end up wherever the browser decides. The fix is to suppress the negative results:

max(0, var(--_n) - var(--_j))

Clamping the minimum to 0 turns every negative value into 0:

10, 9, 8, 7, ... , 0, 0, 0, 0

Zero is an invalid value for grid-column-start, so the browser ignores it and falls back to the default auto-placement. That leaves only the positive values to set the actual column:

grid-column-start:
  if(
    style((--_i > 0) and (--_c: 0)): 2; /* first condition */
    style(--_d: 0): max(0,var(--_n) - var(--_j)); /* second condition */
  );

Now the layout behaves. Add as many items as you want and resize freely — the pyramid builds to its limit, then melts into the classic grid without ever consulting a media query.

Beyond Hexagons

Changing the shape changes the character of the grid. Shape sheets applied to the same structure yield a rhombus, octagon, or circle grid, while the classic hexagon version still works as an alternative. The code is worth tinkering with on your own.

Conclusion

Saying this grid comes down to "one property" was fair in the end — that single grid-column-start declaration carries the entire layout decision. Modern CSS pushes this kind of logic into the stylesheet itself: calculating formulas, defining variables, and building conditions around a few powerful primitives. The old hacks are gone, replaced by implementations that adapt cleanly to any number of elements and any viewport size.