A Modern Take on the Responsive Hexagon Grid
Five years ago, building a responsive hexagon grid without media queries required a careful mix of float, inline-block, and a font-size reset. It worked, but felt hacky. Today, newer CSS features make the same layout considerably cleaner, with less code and fewer magic numbers.
The new approach relies on three bleeding-edge features: corner-shape, sibling-index(), and unit division. Support is currently limited to Chrome, but the technique shows how far CSS has come and points toward where layout logic is heading.
Shaping the Hexagon
The shape itself now uses corner-shape alongside border-radius rather than a complex clip-path: polygon():
.hexagon {
width: 100px;
aspect-ratio: cos(30deg);
border-radius: 50% / 25%;
corner-shape: bevel;
}
This is simpler than the old beveling methods, and as an added bonus, it plays nicely with borders without needing extra workarounds. If you need broader browser support, clip-path remains a valid fallback:
.hexagon {
width: 100px;
aspect-ratio: cos(30deg);
clip-path: polygon(-50% 50%,50% 100%,150% 50%,50% 0);
}
The updated polygon definition also replaces the previous magic number 1.1547 with a more descriptive aspect-ratio declaration.
Constructing the Flexible Grid
Though it is called a grid, the layout is built with flexbox:
<div class="container">
<div></div>
<div></div>
<div></div>
<div></div>
<!-- etc. -->
</div>
.container {
--s: 120px; /* size */
--g: 10px; /* gap */
display: flex;
gap: var(--g);
flex-wrap: wrap;
}
.container > * {
width: var(--s);
aspect-ratio: cos(30deg);
border-radius: 50% / 25%;
corner-shape: bevel;
}
The next step adds a bottom margin to every item, creating the vertical overlap necessary for the row offset:
.container > * {
margin-bottom: calc(var(--s)/(-4*cos(30deg)));
}
The core difficulty is adding a left margin to the first item of each even row. This margin shifts the row horizontally to create the staggered pattern. The tricky part: the row where an item falls changes with the container size, so the "first" item of an even row can be any element at any given viewport width.
Depending on the content and container, the grid either fits the same number of items in every row (your N items), or consecutive rows alternate between N and N - 1 items. In the figure below, the first configuration features identical row counts while the second uses a one-item difference:

In the uniform grid, the staggered items are numbers 6, 16, 26, and so on. In the alternating version, they are 7, 18, and 29. The pattern begins at the first item of the second row (N + 1) and repeats every full cycle of rows. This can be expressed with a general formula:
N*i + M*(i - 1) + 1
Where i is a positive integer larger than zero. The pseudo-code for finding these items looks like this:
for(i = 0; i< ?? ;i++) {
index = N*i + M*(i - 1) + 1
Add margin to items[index]
}
Without loops, CSS needs a different approach. With sibling-index(), each item reports its own position. The task is to test whether that index satisfies the formula. Instead of enumerating cases explicitly:
index = N*i + M*(i - 1) + 1
We solve for i in terms of the item's index:
i = (index - 1 + M)/(N + M)
The condition becomes: whenever (index - 1 + M)/(N + M) evaluates to a positive integer (zero excluded), the item gets a shift.
Before testing individual items, we must first determine N and M, the number of items per row. This is the classic "how many items fit" calculation using container width:
N = round(down,container_size / item_size);
Rounding down the ratio between container size and the item size achieves this, while accounting for gaps between items:
N = round(down, (container_size + gap)/ (item_size + gap));
Computing M for the alternating case includes the shift created by the newly added left margin:
M = round(down, (container_size + gap - margin_left)/ (item_size + gap));
That margin is defined visually in the following figure:

It equals half of the item size plus half of the gap:
M = round(down, (container_size + gap - (item_size + gap)/2)/(item_size + gap));
M = round(down, (container_size - (item_size - gap)/2)/(item_size + gap));
Padding and magic values are avoided: item size and gap are already CSS variables (--s and --g), while the parent width is expressed in container query units (100cqw).
At this point, the code comes together:
.container {
--s: 120px; /* size */
--g: 10px; /* gap */
container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
--_n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--_m: round(down,(100cqw - (var(--s) - var(--g))/2)/(var(--s) + var(--g)));
--_i: calc((sibling-index() - 1 + var(--_m))/(var(--_n) + var(--_m)));
margin-left: ???; /* We're getting there! */
}
The integer check can be handled by mod(var(--_i), 1) — a result of zero means the value is a whole number. One option is introducing a new variable paired with the emerging if() function:
.container {
--s: 120px; /* size */
--g: 10px; /* gap */
container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
--_n: round(down,(100cqw + var(--g))/(var(--s) + var(--g)));
--_m: round(down,(100cqw - (var(--s) - var(--g))/2)/(var(--s) + var(--g)));
--_i: calc((sibling-index() - 1 + var(--_m))/(var(--_n) + var(--_m)));
--_c: mod(var(--_i),1);
margin-left: if(style(--_c: 0) calc((var(--s) + var(--g))/2) else 0;);
}
For this comparison to work, the --_c variable must be registered using @property. A more elegant route skips the registration entirely by turning the result into a boolean via round():
--_c: round(down, 1 - mod(var(--_i), 1));
This method operates purely on arithmetic with no custom property types required.
margin-left: calc(var(--_c) * (var(--s) + var(--g))/2);
When the boolean condition is true, the margin is applied. Not every developer needs to understand the underlying math — adjusting --s and --g is sufficient to scale the grid. The margin logic itself remains unchanged across use cases.
Beyond Hexagons
The same structural code can power a variety of shapes. By modifying only the aspect ratio and border-radius, a hexagon grid becomes a rhombus grid:
.container > * {
aspect-ratio: cos(30deg);
border-radius: 50% / 25%;
corner-shape: bevel;
margin-bottom: calc(var(--s)/(-4*cos(30deg)));
}
.container > * {
aspect-ratio: 1;
border-radius: 50%;
corner-shape: bevel;
margin-bottom: calc(var(--s)/-2);
}
Octagons require a slight gap adjustment:
.container > * {
aspect-ratio: 1;
border-radius: calc(100%/(2 + sqrt(2)));
corner-shape: bevel;
margin-bottom: calc(var(--s)/(-1*(2 + sqrt(2))));
}
.container {
--g: calc(10px + var(--s)/(sqrt(2) + 1));
gap: 10px var(--g);
}
Here, the --g variable incorporates a proportion of the item size yourself (note the 10px fixed column gap versus the adjusted row spacing). The system also supports custom hexagon proportions and plain circle grids by adjusting just those few visual controls.
An Exercise in Reverse
In the main grid design, the shift targets even rows. A final demonstration reverses this: the offset moves to the odd rows. Trace through the revised calculations to understand the change and note where the shared grid mechanics remain intact. Identifying that subtle difference is an excellent way to cement the entire pattern.



