Responsive hexagon grid with modern CSS | CSS tricks

Responsive hexagon grid with modern CSS | CSS tricks

9 minutes, 19 seconds Read

Five years ago I published an article on how to create a responsive grid of hexagonal shapes. It was the only technique that didn’t require media queries or JavaScript. It works with any number of items, making it easy to control size and spacing using CSS variables.

I use float, inline-blocksetting font-size equal to 0etc. In 2026 this may sound a bit hacky and outdated. Not really, as this method works fine and is well supported, but can we do better using modern features? A lot of things have changed in five years and we can improve the above implementation and make it less hacky!

Support is only limited to Chrome as this technique takes advantage of recently released features including corner-shape, sibling-index()And unit division.

The CSS code is shorter and contains fewer magic numbers than the last time I accessed this. You will also find some complex calculations that we will dissect together.

Before I dive into this new demo, I highly recommend reading my previous article first. It’s not mandatory, but it allows you to compare both methods and realize how much (and quickly) CSS has evolved over the past five years by introducing new features that make difficult things like this easier.

The hexagonal shape

Let’s start with the hexagonal shapewhich is the most important element of our grid. I used to have to rely on it clip-path: polygon() to make it:

.hexagon {
  --s: 100px;
  width: var(--s);
  height: calc(var(--s) * 1.1547);
  clip-path: polygon(0% 25%, 0% 75%, 50% 100%, 100% 75%, 100% 25%, 50% 0%);
}

But now we can rely on the new corner-shape property that is next to the border-radius property:

.hexagon {
  width: 100px;
  aspect-ratio: cos(30deg);
  border-radius: 50% / 25%;
  corner-shape: bevel;
}

Easier than how we used to bevel elements, and as a bonus we can add an edge to the shape without workarounds!

The corner-shape property is the first modern characteristic we rely on. It makes signs CSS shapes a lot easier than traditional methods, such as using it clip-path. You can still continue to use the clip-path method of course for better support (and if you don’t need a border on the element), but here’s a more modern version:

.hexagon {
  width: 100px;
  aspect-ratio: cos(30deg);
  clip-path: polygon(-50% 50%,50% 100%,150% 50%,50% 0);
}

There are fewer points within the polygon and we have replaced the magic number 1.1547 immediately aspect-ratio declaration. I won’t spend more time on the code of the shapes, but here are two articles I wrote if you want a detailed explanation with more examples:

The responsive grid

Now that we have our shape, let’s create the grid. It’s called a ‘grid’, but I’m going to use a flexbox configuration:

.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;
}

Nothing special so far. From there we add a bottom margin to all items to create an overlap between the rows:

.container > * {
  margin-bottom: calc(var(--s)/(-4*cos(30deg)));
}

The final step is to add a left margin to the first item of the even rows (i.e. 2nd, 4th, 6th, and so on). This margin allows for the offset between rows to achieve a perfect grid.

Putting it this way, it sounds simple, but it is the trickiest part where we need complex calculations. The grid is responsive, so the “first” item we look for can be any item depending on the container size, item size, opening, etc.

Let's start with a figure:

Our grid can have two aspects depending on its responsiveness. We can have the same number of items in all rows (grid 1 in the image above) or a difference of one item between two consecutive rows (grid 2). The N And M variables represent the number of items in the rows. We did that in Raster 1 N = Mand in Grid 2 we did that too M = N - 1.

In Grid 1, the left margin items are 6, 16, 26, etc., and in Grid 2 they are 7, 18, 29, etc. Let's try to identify the logic behind these numbers.

The first item in either grid (6 or 7) is the first in the second row, so it is the item N + 1. The second item (16 or 18) is the first in the third row, so it is the item N + M + N + 1. The third item (26 or 29) is the item N + M + N + M + N + 1. If you look closely, you will see a pattern that we can express with the following formula:

N*i + M*(i - 1) + 1

…Where i is a positive integer (zero excluded). You can find the items we are looking for using the following pseudo-code:

for(i = 0; i< ?? ;i++) {
  index = N*i + M*(i - 1) + 1
  Add margin to items[index]  
}

However, we don't have loops in CSS, so we'll have to do something else. We can get the index of each item using the new one sibling-index() function. The logic is to test whether that index respects the previous formula.

Instead of writing this:

index = N*i + M*(i - 1) + 1

…let's express it i using the index:

i = (index - 1 + M)/(N + M)

We know that i is a positive integer (zero excluded), so for each item we get the index and test whether (index - 1 + M)/(N + M) is a positive integer. Let's calculate the number of items first, N And M.

Calculating the number of items per row is the same as calculating how many items fit in that row.

N = round(down,container_size / item_size);

If we divide the container size by the item size, we get a number. As us round()' To the nearest integer we get the number of items per row. But there is a gap between the items, so we need to take this into account in the formula:

N = round(down, (container_size + gap)/ (item_size + gap));

We do the same Mbut this time we also need to take into account the left margin applied to the first item of the row:

M = round(down, (container_size + gap - margin_left)/ (item_size + gap));

Let's take a closer look and identify the value of that margin in the following image:

To illustrate the width of a single hexagon shape and the left margin between rows, which is half the width of an item.

It is equal to half the size of an item, plus half the opening:

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));

The item size and opening are defined using the --s And --g variables, but what about container size? We can rely on container query units and usage 100cqw.

Let's write what we have so far using CSS:

.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! */
}

We can use mod(var(--_i),1) to test whether --_i is an integer. If it is an integer, the result is equal to 0. Otherwise, it is equal to a value between 0 and 1.

We can introduce another variable and use the new one 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;);
}

Than!

It is important to note that you must register the variable --_c use variable @property to be able to make the comparison (I will write more about this in “How to use correctly if()in CSS”).

This is a good use case for this if()but we can also do it differently:

--_c: round(down, 1 - mod(var(--_i), 1));

The mod() function gives us a value between 0 and 1, where 0 is the desired value. -1*mod() gives us a value between -1 and 0. 1 - mod() gives us a value between 0 and 1, but this time it's the 1 we need. We apply round() to the calculation, and the result will be 0 or 1 --_c variable is now a Boolean variable that we can use directly within a calculation.

margin-left: calc(var(--_c) * (var(--s) + var(--g))/2);

If --_c equals 1, we get a margin. Otherwise the margin is 0. This time you don't need to register the variable using @property. Personally, I prefer this method because it requires less code, but the if() method is also interesting.

Do I have to memorize all those formulas?! It's too much!

No, you don't. I've tried to give a detailed explanation behind the math, but it's not mandatory to understand it in order to work with the grid. All you have to do is update the variables that determine the size and gap. You don't need to touch the part that sets the left margin. We'll even explore how the same code structure can work with more shapes!

More examples

The most common use is a hexagonal shape, but what about other shapes? For example, we can consider a rhombus and for this we simply adjust the code that controls the shape.

Of this:

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

…to this:

.container > * {
  aspect-ratio: 1;
  border-radius: 50%;
  corner-shape: bevel;
  margin-bottom: calc(var(--s)/-2);
}

A responsive grid of diamond shapes — without any effort! Let's try an octagon:

.container > * {
  aspect-ratio: 1;
  border-radius: calc(100%/(2 + sqrt(2)));
  corner-shape: bevel;
  margin-bottom: calc(var(--s)/(-1*(2 + sqrt(2))));
}

Almost! For an octagon we need the gap because we need more horizontal space between the items:

.container {
  --g: calc(10px + var(--s)/(sqrt(2) + 1));
  gap: 10px var(--g);
}

The variable --g includes part of the size var(--s)/(sqrt(2) + 1) and is applied as a row spacing, while the column spacing remains the same (10px).

From there we can also get another type of hexagon grid:

And why not also a grid of circles? Here we go:

As you can see, in none of these examples have we discussed the complex calculation that determines the left margin. All we had to do was play with the border-radius And aspect-ratio properties to determine the shape and adjust the bottom margin to correct the overlap. In some cases we need to adjust the horizontal opening.

Conclusion

I'll end this article with another demo that will serve as a little homework for you:

This time the shift is applied to the odd rows instead of the even rows. I let you parse the code as a little exercise. Trying to identify the change I made and what the logic is behind it (Tip: try running the calculation steps again with this new configuration.)

#Responsive #hexagon #grid #modern #CSS #CSS #tricks

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *